Compare commits

...
15 Commits
Author SHA1 Message Date
jackandClaude Opus 4.5 1e142dcda8 fix: Remove unused variable and bump version to 1.5.1
- Remove unused messageType variable (compiler warning fix)
- Bump marketing version to 1.5.1

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-28 10:30:38 -10:00
jackandGitHub ef4bdb3856 Merge branch 'main' into main 2026-01-28 07:55:00 -10:00
Ovi bbe1793506 fix: toctou in boundPeerID identified by codex 2026-01-28 10:52:02 +00:00
Ovi af7a664685 Fix iOS BLE mesh authentication bypass chain in BLEService
- Bind sender IDs to BLE connection UUIDs for peripherals and centrals to prevent spoofing
- Enforce explicit RSR request/response validation and remove legacy TTL==0 RSR path
- Remove TTL==0 unconditional acceptance for messages and file transfers
- Ensure gossip sync caching only occurs after a packet is accepted
- Preserve self‑sync TTL==0 dedup exception without weakening authentication
2026-01-27 15:14:21 +00:00
GitHub Action b4b6aa5ca6 Automated update of relay data - Sun Jan 25 06:05:14 UTC 2026 2026-01-25 06:05:14 +00:00
GitHub Action 1e5a52f39f Automated update of relay data - Sun Jan 18 06:05:08 UTC 2026 2026-01-18 06:05:08 +00:00
3fc64f6168 feat: Implement Request Sync Manager (V2 Sync) (#965)
* feat: Implement Request Sync Manager (V2 Sync)

- Add RequestSyncManager to track and attribute sync requests
- Update BitchatPacket and BinaryProtocol to support IS_RSR flag (0x10)
- Update RequestSyncPacket with new TLV fields (sinceTimestamp, fragmentIdFilter)
- Update GossipSyncManager to use unicast sync requests and mark responses as RSR
- Update BLEService to enforce timestamp validation for normal packets and exempt valid RSRs
- Add documentation for the new sync manager mechanism

* fix: Resolve compilation errors in V2 Sync implementation

- Remove duplicate restartGossipManager in BLEService
- Add missing TransportConfig constants for sync
- Add 'sync' log category to BitLogger
- Add missing BitLogger import in GossipSyncManager

* fix: Update tests for V2 Sync changes

- Add requestSyncManager parameter to GossipSyncManager init in tests
- Implement getConnectedPeers stub in RecordingDelegate
- Remove unused variable warning in SubscriptionRateLimitTests

---------

Co-authored-by: a1denvalu3 <>
Co-authored-by: jack <212554440+jackjackbits@users.noreply.github.com>
2026-01-17 07:43:02 -10:00
9964710de2 Improve BLE mesh reliability for large transfers (#964)
* Improve BLE mesh reliability for large transfers

Major reliability improvements for fragment-based transfers (photos, files):

**Notification Queue Fixes**
- Fix silent packet loss when notification queue is full - now queues for retry
- Fix retry queue bug where remaining items were lost when one retry failed
- Add periodic drain mechanism as backup (every 5 seconds)

**Write Queue Fixes**
- Fix drainPendingWrites to use atomic take-send-requeue pattern
- Add logging when peripheral is ready for more writes
- Add periodic drain for pending writes as backup

**Fragment Pacing**
- Increase fragment spacing from 4-5ms to 25-30ms to prevent buffer overflow
- Conservative pacing prevents packet loss on congested BLE connections

**Thread Safety**
- Fix race conditions in stopServices() and emergencyDisconnectAll()
- Synchronize access to peripherals/centrals dictionaries during cleanup
- Clear pending message queues in emergencyDisconnectAll()

**Error Recovery**
- Add handlers for all BLE state transitions (poweredOff, unauthorized, etc.)
- Clear Noise session and re-initiate handshake on decryption failure
- Queue ACKs/receipts for delivery after handshake instead of dropping
- Re-queue failed pending messages for retry

**Other Improvements**
- Add route freshness validation in MeshTopologyTracker (60s threshold)
- Add TTL (24h) and size limits (100 per peer) for MessageRouter outbox
- Fix connection timeout to check peripheral.state before canceling
- Add NoiseEncryptionService.clearSession(for:) method

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix race condition and increase test timeouts

- Fix race in sendNoisePayload: use sync barrier instead of async
  to ensure payload is queued before initiating handshake
  (addresses Codex review feedback)

- Increase BLEServiceTests sleep from 0.5s to 1.0s for CI reliability

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 07:39:31 -10:00
806c420313 Add comprehensive test coverage for ChatViewModel and BLEService (#962)
* Add comprehensive test coverage for ChatViewModel and BLEService

This commit adds 14 new tests to improve the safety net for future
refactoring of ChatViewModel and BLEService:

New test files:
- ChatViewModelTorTests: Tor lifecycle notification handlers (8 tests)
- ChatViewModelDeliveryStatusTests: Delivery status state machine (6 tests)
- ChatViewModelRefactoringTests: Command routing and message handling (4 tests)
- BLEServiceCoreTests: Packet deduplication and stale broadcast filtering (2 tests)
- PublicMessagePipelineTests: Message ordering and deduplication (4 tests)
- MessageRouterTests: Transport selection and outbox behavior (4 tests)
- PrivateChatManagerTests: Chat selection and read receipts (2 tests)
- UnifiedPeerServiceTests: Fingerprint resolution and blocking (2 tests)
- RelayControllerTests: TTL, handshake, and fragment relay logic (4 tests)

Modified files:
- MockTransport: Added peer snapshot publishing on connect/disconnect
- TestHelpers: Added waitUntil polling helper for async tests
- MockIdentityManager: Extended for new test scenarios

Total: 454 tests across 64 suites (was 440)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Fix flaky test by using waitUntil instead of fixed sleep

Replace fixed 200ms sleep with waitUntil helper for more reliable
async assertion in routing tests. This prevents timing issues on CI.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: jack <jackjackbits@users.noreply.github.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 16:02:13 -10:00
jackandGitHub 194dedac43 Merge pull request #961 from permissionlesstech/chore/cleanup-dead-code
Remove dead code and stabilize tests
2026-01-16 08:42:10 -10:00
jack 9af46a9ff8 Normalize Cashu chip URLs 2026-01-15 09:57:40 -10:00
jack da3fcd5a21 Remove dead code and stabilize tests 2026-01-15 09:46:35 -10:00
jackandGitHub b282536080 Merge pull request #960 from permissionlesstech/cleanup/dead-code-and-helpers
Remove dead code and extract helper methods
2026-01-15 07:18:06 -10:00
jackandGitHub e156356c71 Update README.md 2026-01-14 14:39:39 -10:00
jackandClaude Opus 4.5 81a6e18d04 Remove dead code and extract helper methods
- Delete LocalizationCatalogTests.swift (530 lines entirely commented out)
- Remove unused nilIfEmpty String extension
- Remove backward-compat aliases from CommandProcessor
- Extract reachableTransport/connectedTransport helpers in MessageRouter
- Extract npubToHex/sendWrappedMessage helpers in NostrTransport
- Refactor 7 message sending methods to use shared helpers

Net reduction: ~590 lines

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 14:09:58 -10:00
45 changed files with 2701 additions and 1321 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
MARKETING_VERSION = 1.5.0
MARKETING_VERSION = 1.5.1
CURRENT_PROJECT_VERSION = 1
IPHONEOS_DEPLOYMENT_TARGET = 16.0
-3
View File
@@ -8,9 +8,6 @@ A decentralized peer-to-peer messaging app with dual transport architecture: loc
📲 [App Store](https://apps.apple.com/us/app/bitchat-mesh/id6748219622)
> [!WARNING]
> Private messages have not received external security review and may contain vulnerabilities. Do not use for sensitive use cases, and do not rely on its security until it has been reviewed. Now uses the [Noise Protocol](https://www.noiseprotocol.org) for identity and encryption. Public local chat (the main feature) has no security concerns.
## License
This project is released into the public domain. See the [LICENSE](LICENSE) file for details.
-5
View File
@@ -279,8 +279,3 @@ final class NotificationDelegate: NSObject, UNUserNotificationCenterDelegate {
}
}
extension String {
var nilIfEmpty: String? {
self.isEmpty ? nil : self
}
}
+7 -3
View File
@@ -22,8 +22,9 @@ struct BitchatPacket: Codable {
var signature: Data?
var ttl: UInt8
var route: [Data]?
var isRSR: Bool
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil) {
init(type: UInt8, senderID: Data, recipientID: Data?, timestamp: UInt64, payload: Data, signature: Data?, ttl: UInt8, version: UInt8 = 1, route: [Data]? = nil, isRSR: Bool = false) {
self.version = version
self.type = type
self.senderID = senderID
@@ -33,10 +34,11 @@ struct BitchatPacket: Codable {
self.signature = signature
self.ttl = ttl
self.route = route
self.isRSR = isRSR
}
// Convenience initializer for new binary format
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data) {
init(type: UInt8, ttl: UInt8, senderID: PeerID, payload: Data, isRSR: Bool = false) {
self.version = 1
self.type = type
// Convert hex string peer ID to binary data (8 bytes)
@@ -56,6 +58,7 @@ struct BitchatPacket: Codable {
self.signature = nil
self.ttl = ttl
self.route = nil
self.isRSR = isRSR
}
var data: Data? {
@@ -85,7 +88,8 @@ struct BitchatPacket: Codable {
signature: nil, // Remove signature for signing
ttl: 0, // Use fixed TTL=0 for signing to ensure relay compatibility
version: version,
route: route
route: route,
isRSR: false // RSR flag is mutable and not part of the signature
)
return BinaryProtocol.encode(unsignedPacket)
}
+26 -3
View File
@@ -9,12 +9,16 @@ struct RequestSyncPacket {
let m: UInt32
let data: Data
let types: SyncTypeFlags?
let sinceTimestamp: UInt64?
let fragmentIdFilter: String?
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil) {
init(p: Int, m: UInt32, data: Data, types: SyncTypeFlags? = nil, sinceTimestamp: UInt64? = nil, fragmentIdFilter: String? = nil) {
self.p = p
self.m = m
self.data = data
self.types = types
self.sinceTimestamp = sinceTimestamp
self.fragmentIdFilter = fragmentIdFilter
}
func encode() -> Data {
@@ -36,15 +40,24 @@ struct RequestSyncPacket {
if let typesData = types?.toData() {
putTLV(0x04, typesData)
}
if let ts = sinceTimestamp {
var tsBE = ts.bigEndian
putTLV(0x05, withUnsafeBytes(of: &tsBE) { Data($0) })
}
if let fid = fragmentIdFilter, let fidData = fid.data(using: .utf8) {
putTLV(0x06, fidData)
}
return out
}
static func decode(from data: Data, maxAcceptBytes: Int = 1024) -> RequestSyncPacket? {
var off = 0
var p: Int? = nil
var m: UInt32? = nil
var payload: Data? = nil
var types: SyncTypeFlags? = nil
var sinceTimestamp: UInt64? = nil
var fragmentIdFilter: String? = nil
while off + 3 <= data.count {
let t = Int(data[off]); off += 1
@@ -68,12 +81,22 @@ struct RequestSyncPacket {
if let decoded = SyncTypeFlags.decode(v) {
types = decoded
}
case 0x05:
if v.count == 8 {
var ts: UInt64 = 0
for b in v { ts = (ts << 8) | UInt64(b) }
sinceTimestamp = ts
}
case 0x06:
if let fid = String(data: v, encoding: .utf8) {
fragmentIdFilter = fid
}
default:
break // forward compatible; ignore unknown TLVs
}
}
guard let pp = p, let mm = m, let dd = payload, pp >= 1, mm > 0 else { return nil }
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types)
return RequestSyncPacket(p: pp, m: mm, data: dd, types: types, sinceTimestamp: sinceTimestamp, fragmentIdFilter: fragmentIdFilter)
}
}
-3
View File
@@ -69,7 +69,6 @@ final class NostrRelayManager: ObservableObject {
private var messageQueue: [PendingSend] = []
private let messageQueueLock = NSLock()
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
private var networkService: NetworkActivationService { NetworkActivationService.shared }
private var shouldUseTor: Bool { networkService.userTorEnabled }
@@ -79,8 +78,6 @@ final class NostrRelayManager: ObservableObject {
private let backoffMultiplier: Double = TransportConfig.nostrRelayBackoffMultiplier
private let maxReconnectAttempts = TransportConfig.nostrRelayMaxReconnectAttempts
// Reconnection timer
private var reconnectionTimer: Timer?
// Bump generation to invalidate scheduled reconnects when we reset/disconnect
private var connectionGeneration: Int = 0
+7 -3
View File
@@ -138,6 +138,7 @@ struct BinaryProtocol {
static let hasSignature: UInt8 = 0x02
static let isCompressed: UInt8 = 0x04
static let hasRoute: UInt8 = 0x08
static let isRSR: UInt8 = 0x10
}
// Encode BitchatPacket to binary format
@@ -204,8 +205,9 @@ struct BinaryProtocol {
if isCompressed { flags |= Flags.isCompressed }
// HAS_ROUTE is only valid for v2+ packets
if hasRoute && version >= 2 { flags |= Flags.hasRoute }
if packet.isRSR { flags |= Flags.isRSR }
data.append(flags)
if version == 2 {
let length = UInt32(payloadDataSize)
for shift in stride(from: 24, through: 0, by: -8) {
@@ -329,7 +331,8 @@ struct BinaryProtocol {
let isCompressed = (flags & Flags.isCompressed) != 0
// HAS_ROUTE is only valid for v2+ packets; ignore the flag for v1
let hasRoute = (version >= 2) && (flags & Flags.hasRoute) != 0
let isRSR = (flags & Flags.isRSR) != 0
let payloadLength: Int
if version == 2 {
guard let len = read32() else { return nil }
@@ -410,7 +413,8 @@ struct BinaryProtocol {
signature: signature,
ttl: ttl,
version: version,
route: route
route: route,
isRSR: isRSR
)
}
}
+410 -158
View File
@@ -7,7 +7,6 @@ import CryptoKit
import UIKit
#endif
/// BLEService Bluetooth Mesh Transport
/// - Emits events exclusively via `BitchatDelegate` for UI.
/// - ChatViewModel must consume delegate callbacks (`didReceivePublicMessage`, `didReceiveNoisePayload`).
@@ -138,11 +137,6 @@ final class BLEService: NSObject {
private var pendingMessagesAfterHandshake: [PeerID: [(content: String, messageID: String)]] = [:]
// Noise typed payloads (ACKs, read receipts, etc.) pending handshake
private var pendingNoisePayloadsAfterHandshake: [PeerID: [Data]] = [:]
// Keep a tiny buffer of the last few unique announces we've seen (by sender)
private var recentAnnounceBySender: [PeerID: BitchatPacket] = [:]
private var recentAnnounceOrder: [PeerID] = []
private let recentAnnounceBufferCap = 3
// Queue for notifications that failed due to full queue
private var pendingNotifications: [(data: Data, centrals: [CBCentral]?)] = []
@@ -200,6 +194,7 @@ final class BLEService: NSObject {
// MARK: - Gossip Sync
private var gossipSyncManager: GossipSyncManager?
private let requestSyncManager = RequestSyncManager()
// MARK: - Maintenance Timer
@@ -333,6 +328,31 @@ final class BLEService: NSObject {
// Initialize gossip sync manager
restartGossipManager()
}
private func restartGossipManager() {
// Stop existing
gossipSyncManager?.stop()
let config = GossipSyncManager.Config(
seenCapacity: TransportConfig.syncSeenCapacity,
gcsMaxBytes: TransportConfig.syncGCSMaxBytes,
gcsTargetFpr: TransportConfig.syncGCSTargetFpr,
maxMessageAgeSeconds: TransportConfig.syncMaxMessageAgeSeconds,
maintenanceIntervalSeconds: TransportConfig.syncMaintenanceIntervalSeconds,
stalePeerCleanupIntervalSeconds: TransportConfig.syncStalePeerCleanupIntervalSeconds,
stalePeerTimeoutSeconds: TransportConfig.syncStalePeerTimeoutSeconds,
fragmentCapacity: TransportConfig.syncFragmentCapacity,
fileTransferCapacity: TransportConfig.syncFileTransferCapacity,
fragmentSyncIntervalSeconds: TransportConfig.syncFragmentIntervalSeconds,
fileTransferSyncIntervalSeconds: TransportConfig.syncFileTransferIntervalSeconds,
messageSyncIntervalSeconds: TransportConfig.syncMessageIntervalSeconds
)
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
manager.delegate = self
manager.start()
gossipSyncManager = manager
}
// No advertising policy to set; we never include Local Name in adverts.
@@ -354,8 +374,6 @@ final class BLEService: NSObject {
}
collectionsQueue.sync(flags: .barrier) {
recentAnnounceBySender.removeAll()
recentAnnounceOrder.removeAll()
pendingPeripheralWrites.removeAll()
pendingFragmentTransfers.removeAll()
pendingNotifications.removeAll()
@@ -514,45 +532,52 @@ final class BLEService: NSObject {
signature: nil,
ttl: messageTTL
)
// Send immediately to all connected peers
// Send immediately to all connected peers (synchronized access to BLE state)
if let data = leavePacket.toBinaryData(padding: false) {
let leavePriority = priority(for: leavePacket, data: data)
// Snapshot BLE state under bleQueue to avoid races with delegate callbacks
let (peripheralStates, centralsCount, char) = bleQueue.sync {
(Array(peripherals.values), subscribedCentrals.count, characteristic)
}
// Send to peripherals we're connected to as central
for state in peripherals.values where state.isConnected {
for state in peripheralStates where state.isConnected {
if let characteristic = state.characteristic {
writeOrEnqueue(data, to: state.peripheral, characteristic: characteristic, priority: leavePriority)
}
}
// Send to centrals subscribed to us as peripheral
if subscribedCentrals.count > 0 && characteristic != nil {
peripheralManager?.updateValue(data, for: characteristic!, onSubscribedCentrals: nil)
if centralsCount > 0, let ch = char {
peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: nil)
}
}
// Give leave message a moment to send (cooperative delay allows BLE callbacks to fire)
let deadline = Date().addingTimeInterval(TransportConfig.bleThreadSleepWriteShortDelaySeconds)
while Date() < deadline {
RunLoop.current.run(until: Date().addingTimeInterval(0.01))
}
// Clear pending notifications
collectionsQueue.sync(flags: .barrier) {
pendingNotifications.removeAll()
}
// Stop timer
maintenanceTimer?.cancel()
maintenanceTimer = nil
scanDutyTimer?.cancel()
scanDutyTimer = nil
centralManager?.stopScan()
peripheralManager?.stopAdvertising()
// Disconnect all peripherals
for state in peripherals.values {
// Disconnect all peripherals (synchronized access)
let peripheralsToDisconnect = bleQueue.sync { Array(peripherals.values) }
for state in peripheralsToDisconnect {
centralManager?.cancelPeripheralConnection(state.peripheral)
}
}
@@ -567,6 +592,10 @@ final class BLEService: NSObject {
incomingFragments.removeAll()
fragmentMetadata.removeAll()
activeTransfers.removeAll()
// Also clear pending message queues to avoid stale state across sessions
pendingMessagesAfterHandshake.removeAll()
pendingNoisePayloadsAfterHandshake.removeAll()
pendingDirectedRelays.removeAll()
return entries
}
@@ -578,15 +607,15 @@ final class BLEService: NSObject {
// Clear processed messages
messageDeduplicator.reset()
// Clear peripheral references
peripherals.removeAll()
peerToPeripheralUUID.removeAll()
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
// Clear peripheral references (synchronized access to avoid races with BLE callbacks)
bleQueue.sync {
peripherals.removeAll()
peerToPeripheralUUID.removeAll()
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
centralSubscriptionRateLimits.removeAll()
}
meshTopology.reset()
// BCH-01-004: Clear rate-limit state
centralSubscriptionRateLimits.removeAll()
}
// MARK: Connectivity and peers
@@ -790,6 +819,42 @@ final class BLEService: NSObject {
}
}
private enum ConnectionSource {
case peripheral(String)
case central(String)
case unknown
}
private func validatePacket(_ packet: BitchatPacket, from peerID: PeerID, connectionSource: ConnectionSource = .unknown) -> Bool {
let currentTime = UInt64(Date().timeIntervalSince1970 * 1000)
let isRSR = packet.isRSR
var skipTimestampCheck = false
if isRSR {
if requestSyncManager.isValidResponse(from: peerID, isRSR: true) {
SecureLogger.debug("Valid RSR packet from \(peerID.id.prefix(8))… - skipping timestamp check", category: .security)
skipTimestampCheck = true
} else {
SecureLogger.warning("Invalid or unsolicited RSR packet from \(peerID.id.prefix(8))… - rejecting", category: .security)
return false
}
}
if !skipTimestampCheck {
let maxSkew: UInt64 = 120_000
let packetTime = packet.timestamp
let skew = (packetTime > currentTime) ? (packetTime - currentTime) : (currentTime - packetTime)
if skew > maxSkew {
SecureLogger.warning("Packet timestamp skewed by \(skew)ms (max \(maxSkew)ms) from \(peerID.id.prefix(8))", category: .security)
return false
}
}
return true
}
// MARK: - Packet Broadcasting
private func broadcastPacket(_ packet: BitchatPacket, transferId: String? = nil) {
@@ -1006,7 +1071,13 @@ final class BLEService: NSObject {
if let ch = characteristic {
let targets = subscribedCentrals.filter { selectedCentralIDs.contains($0.identifier.uuidString) }
if !targets.isEmpty {
_ = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets)
let success = peripheralManager?.updateValue(data, for: ch, onSubscribedCentrals: targets) ?? false
if !success {
// Notification queue full - queue for retry to prevent silent packet loss
// This is critical for fragment delivery reliability
let context = packet.type == MessageType.fragment.rawValue ? "fragment" : "broadcast"
enqueuePendingNotification(data: data, centrals: targets, context: context)
}
}
}
}
@@ -1053,34 +1124,6 @@ final class BLEService: NSObject {
}
}
private func rebroadcastRecentAnnounces() {
// Snapshot sender order to preserve ordering and avoid holding locks while sending
let packets: [BitchatPacket] = collectionsQueue.sync {
recentAnnounceOrder.compactMap { recentAnnounceBySender[$0] }
}
guard !packets.isEmpty else { return }
for (idx, pkt) in packets.enumerated() {
// Stagger slightly to avoid bursts
let delayMs = idx * 20
messageQueue.asyncAfter(deadline: .now() + .milliseconds(delayMs)) { [weak self] in
self?.broadcastPacket(pkt)
}
}
}
private func sendData(_ data: Data, to peripheral: CBPeripheral) {
// Fire-and-forget: Simple send without complex fallback logic
guard peripheral.state == .connected else { return }
let peripheralUUID = peripheral.identifier.uuidString
guard let state = peripherals[peripheralUUID],
let characteristic = state.characteristic else { return }
// Fire-and-forget principle: always use .withoutResponse for speed
// CoreBluetooth will handle fragmentation at L2CAP layer
writeOrEnqueue(data, to: peripheral, characteristic: characteristic, priority: .high)
}
private func handleFileTransfer(_ packet: BitchatPacket, from peerID: PeerID) {
if peerID == myPeerID && packet.ttl != 0 { return }
@@ -1120,13 +1163,6 @@ final class BLEService: NSObject {
break
}
}
if !accepted && packet.ttl == 0 {
accepted = true
senderNickname = "anon" + String(peerID.id.prefix(4))
}
} else if packet.ttl == 0 {
accepted = true
senderNickname = "anon" + String(peerID.id.prefix(4))
}
guard accepted else {
@@ -1474,17 +1510,6 @@ final class BLEService: NSObject {
}
}
private func sendLeave() {
SecureLogger.debug("👋 Sending leave announcement", category: .session)
let packet = BitchatPacket(
type: MessageType.leave.rawValue,
ttl: messageTTL,
senderID: myPeerID,
payload: Data(myNickname.utf8)
)
broadcastPacket(packet)
}
private func sendAnnounce(forceSend: Bool = false) {
// Throttle announces to prevent flooding
let now = Date()
@@ -1576,6 +1601,12 @@ extension BLEService: GossipSyncManager.Delegate {
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
return noiseService.signPacket(packet) ?? packet
}
func getConnectedPeers() -> [PeerID] {
return collectionsQueue.sync {
peers.values.compactMap { $0.isConnected ? $0.peerID : nil }
}
}
}
// MARK: - CBCentralManagerDelegate
@@ -1629,9 +1660,50 @@ extension BLEService: CBCentralManagerDelegate {
self.delegate?.didUpdateBluetoothState(central.state)
}
if central.state == .poweredOn {
switch central.state {
case .poweredOn:
// Start scanning - use allow duplicates for faster discovery when active
startScanning()
case .poweredOff:
// Bluetooth was turned off - stop scanning and clean up connection state
SecureLogger.info("📴 Bluetooth powered off - cleaning up central state", category: .session)
central.stopScan()
// Mark all peripheral connections as disconnected (they are now invalid)
let peerIDs: [PeerID] = peripherals.compactMap { $0.value.peerID }
for state in peripherals.values {
central.cancelPeripheralConnection(state.peripheral)
}
peripherals.removeAll()
peerToPeripheralUUID.removeAll()
// Notify UI of disconnections
for peerID in peerIDs {
notifyUI { [weak self] in
self?.notifyPeerDisconnectedDebounced(peerID)
}
}
case .unauthorized:
// User denied Bluetooth permission
SecureLogger.warning("🚫 Bluetooth unauthorized - user denied permission", category: .session)
central.stopScan()
peripherals.removeAll()
peerToPeripheralUUID.removeAll()
case .unsupported:
// Device doesn't support BLE
SecureLogger.error("❌ Bluetooth LE not supported on this device", category: .session)
case .resetting:
// Bluetooth stack is resetting - will get another state update when done
SecureLogger.info("🔄 Bluetooth stack resetting...", category: .session)
case .unknown:
// Initial state before we know the actual state
SecureLogger.debug("❓ Bluetooth state unknown (initializing)", category: .session)
@unknown default:
SecureLogger.warning("⚠️ Unknown Bluetooth state: \(central.state.rawValue)", category: .session)
}
}
@@ -1772,15 +1844,22 @@ extension BLEService: CBCentralManagerDelegate {
guard let self = self,
let state = self.peripherals[peripheralID],
state.isConnecting && !state.isConnected else { return }
// Double-check actual CBPeripheral state to avoid canceling a just-connected peripheral
// This prevents a race where connection completes just as timeout fires
guard peripheral.state != .connected else {
SecureLogger.debug("⏱️ Timeout fired but peripheral already connected: \(advertisedName)", category: .session)
return
}
// Connection timed out - cancel it
SecureLogger.debug("⏱️ Timeout: \(advertisedName)", category: .session)
central.cancelPeripheralConnection(peripheral)
self.peripherals[peripheralID] = nil
self.recentConnectTimeouts[peripheralID] = Date()
self.failureCounts[peripheralID, default: 0] += 1
// Try next candidate if any
self.tryConnectFromQueue()
central.cancelPeripheralConnection(peripheral)
self.peripherals[peripheralID] = nil
self.recentConnectTimeouts[peripheralID] = Date()
self.failureCounts[peripheralID, default: 0] += 1
// Try next candidate if any
self.tryConnectFromQueue()
}
}
@@ -2059,8 +2138,6 @@ extension BLEService: CBPeripheralDelegate {
self?.sendAnnounce(forceSend: true)
// Try flushing any spooled directed packets now that we have a link
self?.flushDirectedSpool()
// Rebroadcast a couple of recent announces to seed the new link
self?.rebroadcastRecentAnnounces()
}
} else {
SecureLogger.warning("⚠️ Characteristic does not support notifications", category: .session)
@@ -2106,6 +2183,13 @@ extension BLEService: CBPeripheralDelegate {
if result.reset {
SecureLogger.error("❌ Invalid BLE frame length; reset notification stream", category: .session)
}
// Codex review identified TOCTOU in this patch.
// Enforce per-link sender binding immediately within the same notification batch.
// NOTE: `processNotificationPacket` may bind `peripherals[peripheralUUID].peerID` when an announce
// is processed, but `state` above is a snapshot. Track a local binding that we update as soon as
// we see a binding-eligible announce so subsequent frames can't spoof a different sender.
var boundPeerID: PeerID? = state.peerID
for frame in result.frames {
guard let packet = BinaryProtocol.decode(frame) else {
@@ -2113,6 +2197,32 @@ extension BLEService: CBPeripheralDelegate {
SecureLogger.error("❌ Failed to decode assembled notification frame (len=\(frame.count), prefix=\(prefix))", category: .session)
continue
}
let claimedSenderID = PeerID(hexData: packet.senderID)
let trustedSenderID: PeerID?
if let knownPeerID = boundPeerID {
if knownPeerID != claimedSenderID {
SecureLogger.warning("🚫 SECURITY: Sender ID spoofing attempt detected! Peripheral \(peripheralUUID.prefix(8))… claimed to be \(claimedSenderID.id.prefix(8))… but is bound to \(knownPeerID.id.prefix(8))", category: .security)
continue
}
trustedSenderID = knownPeerID
} else {
trustedSenderID = nil
}
if !validatePacket(packet, from: trustedSenderID ?? claimedSenderID, connectionSource: .peripheral(peripheralUUID)) {
continue
}
// If this is a direct-link announce, bind immediately for the remainder of this batch.
if boundPeerID == nil,
packet.type == MessageType.announce.rawValue,
packet.ttl == messageTTL {
boundPeerID = claimedSenderID
state.peerID = claimedSenderID
peripherals[peripheralUUID] = state
}
processNotificationPacket(packet, from: peripheral, peripheralUUID: peripheralUUID)
}
}
@@ -2158,7 +2268,8 @@ extension BLEService: CBPeripheralDelegate {
}
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
// Resume queued writes for this peripheral
// Resume queued writes for this peripheral - called when canSendWriteWithoutResponse becomes true again
SecureLogger.debug("📤 Peripheral \(peripheral.name ?? peripheral.identifier.uuidString.prefix(8).description) ready for more writes", category: .session)
drainPendingWrites(for: peripheral)
}
@@ -2191,6 +2302,7 @@ extension BLEService: CBPeripheralDelegate {
}
}
}
}
// MARK: - CBPeripheralManagerDelegate
@@ -2198,11 +2310,12 @@ extension BLEService: CBPeripheralDelegate {
extension BLEService: CBPeripheralManagerDelegate {
func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) {
SecureLogger.debug("📡 Peripheral manager state: \(peripheral.state.rawValue)", category: .session)
if peripheral.state == .poweredOn {
switch peripheral.state {
case .poweredOn:
// Remove all services first to ensure clean state
peripheral.removeAllServices()
// Create characteristic
characteristic = CBMutableCharacteristic(
type: BLEService.characteristicUUID,
@@ -2210,14 +2323,54 @@ extension BLEService: CBPeripheralManagerDelegate {
value: nil,
permissions: [.readable, .writeable]
)
// Create service
let service = CBMutableService(type: BLEService.serviceUUID, primary: true)
service.characteristics = [characteristic!]
// Add service (advertising will start in didAdd delegate)
SecureLogger.debug("🔧 Adding BLE service...", category: .session)
peripheral.add(service)
case .poweredOff:
// Bluetooth was turned off - clean up peripheral state
SecureLogger.info("📴 Bluetooth powered off - cleaning up peripheral state", category: .session)
peripheral.stopAdvertising()
// Clear subscribed centrals (they are now invalid)
let centralPeerIDs = centralToPeerID.values.map { $0 }
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
centralSubscriptionRateLimits.removeAll()
characteristic = nil
// Notify UI of disconnections
for peerID in centralPeerIDs {
notifyUI { [weak self] in
self?.notifyPeerDisconnectedDebounced(peerID)
}
}
case .unauthorized:
// User denied Bluetooth permission
SecureLogger.warning("🚫 Bluetooth unauthorized for peripheral role", category: .session)
peripheral.stopAdvertising()
subscribedCentrals.removeAll()
centralToPeerID.removeAll()
centralSubscriptionRateLimits.removeAll()
characteristic = nil
case .unsupported:
// Device doesn't support BLE peripheral role
SecureLogger.error("❌ Bluetooth LE peripheral role not supported", category: .session)
case .resetting:
// Bluetooth stack is resetting
SecureLogger.info("🔄 Bluetooth peripheral stack resetting...", category: .session)
case .unknown:
SecureLogger.debug("❓ Peripheral Bluetooth state unknown (initializing)", category: .session)
@unknown default:
SecureLogger.warning("⚠️ Unknown peripheral Bluetooth state: \(peripheral.state.rawValue)", category: .session)
}
}
@@ -2302,10 +2455,9 @@ extension BLEService: CBPeripheralManagerDelegate {
return
}
// Still flush directed packets and rebroadcast for legitimate mesh operation
// Still flush directed packets for legitimate mesh operation
messageQueue.asyncAfter(deadline: .now() + TransportConfig.blePostAnnounceDelaySeconds) { [weak self] in
self?.flushDirectedSpool()
self?.rebroadcastRecentAnnounces()
}
return
}
@@ -2331,8 +2483,6 @@ extension BLEService: CBPeripheralManagerDelegate {
self?.sendAnnounce(forceSend: true)
// Flush any spooled directed packets now that we have a central subscribed
self?.flushDirectedSpool()
// Rebroadcast a couple of recent announces to seed the new link
self?.rebroadcastRecentAnnounces()
}
}
@@ -2398,28 +2548,38 @@ extension BLEService: CBPeripheralManagerDelegate {
self.pendingNotifications.removeAll()
// Try to send pending notifications
for (data, centrals) in pending {
var sentCount = 0
for (index, (data, centrals)) in pending.enumerated() {
if let centrals = centrals {
// Send to specific centrals
let success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: centrals) ?? false
if !success {
// Still full, re-queue
self.pendingNotifications.append((data: data, centrals: centrals))
SecureLogger.debug("⚠️ Notification queue still full, re-queuing", category: .session)
// Still full, re-queue this and all remaining items
let remaining = pending.dropFirst(index)
self.pendingNotifications.append(contentsOf: remaining)
SecureLogger.debug("⚠️ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items", category: .session)
break // Stop trying, wait for next ready callback
} else {
SecureLogger.debug("✅ Sent pending notification from retry queue", category: .session)
sentCount += 1
}
} else {
// Broadcast to all
let success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil) ?? false
if !success {
// Still full, re-queue
self.pendingNotifications.append((data: data, centrals: nil))
// Still full, re-queue this and all remaining items
let remaining = pending.dropFirst(index)
self.pendingNotifications.append(contentsOf: remaining)
SecureLogger.debug("⚠️ Notification queue still full after \(sentCount) sent, re-queuing \(remaining.count) items", category: .session)
break
} else {
sentCount += 1
}
}
}
if sentCount > 0 {
SecureLogger.debug("✅ Sent \(sentCount) pending notifications from retry queue", category: .session)
}
if !self.pendingNotifications.isEmpty {
SecureLogger.debug("📋 Still have \(self.pendingNotifications.count) pending notifications", category: .session)
@@ -2477,16 +2637,33 @@ extension BLEService: CBPeripheralManagerDelegate {
if let packet = BinaryProtocol.decode(combined) {
// Clear buffer on success
pendingWriteBuffers.removeValue(forKey: centralUUID)
let senderID = PeerID(hexData: packet.senderID)
let claimedSenderID = PeerID(hexData: packet.senderID)
let trustedSenderID: PeerID?
if let knownPeerID = centralToPeerID[centralUUID] {
if knownPeerID != claimedSenderID {
SecureLogger.warning("🚫 SECURITY: Sender ID spoofing attempt detected! Central \(centralUUID.prefix(8))… claimed to be \(claimedSenderID.id.prefix(8))… but is bound to \(knownPeerID.id.prefix(8))", category: .security)
continue
}
trustedSenderID = knownPeerID
} else {
trustedSenderID = nil
}
if !validatePacket(packet, from: trustedSenderID ?? claimedSenderID, connectionSource: .central(centralUUID)) {
continue
}
if packet.type != MessageType.announce.rawValue {
SecureLogger.debug("📦 Decoded (combined) packet type: \(packet.type) from sender: \(senderID)", category: .session)
SecureLogger.debug("📦 Decoded (combined) packet type: \(packet.type) from sender: \(claimedSenderID)", category: .session)
}
if !subscribedCentrals.contains(sorted[0].central) {
subscribedCentrals.append(sorted[0].central)
}
if packet.type == MessageType.announce.rawValue {
if packet.ttl == messageTTL {
centralToPeerID[centralUUID] = senderID
centralToPeerID[centralUUID] = claimedSenderID
refreshLocalTopology()
}
// Record ingress link for last-hop suppression then process
@@ -2494,14 +2671,14 @@ extension BLEService: CBPeripheralManagerDelegate {
collectionsQueue.async(flags: .barrier) { [weak self] in
self?.ingressByMessageID[msgID] = (.central(centralUUID), Date())
}
handleReceivedPacket(packet, from: senderID)
handleReceivedPacket(packet, from: claimedSenderID)
} else {
// Record ingress link for last-hop suppression then process
let msgID = makeMessageID(for: packet)
collectionsQueue.async(flags: .barrier) { [weak self] in
self?.ingressByMessageID[msgID] = (.central(centralUUID), Date())
}
handleReceivedPacket(packet, from: senderID)
handleReceivedPacket(packet, from: claimedSenderID)
}
} else {
// If buffer grows suspiciously large, reset to avoid memory leak
@@ -2726,17 +2903,19 @@ extension BLEService {
meshTopology.reset()
}
private func restartGossipManager() {
gossipSyncManager?.stop()
let sync = GossipSyncManager(myPeerID: myPeerID)
sync.delegate = self
sync.start()
gossipSyncManager = sync
}
private func sendNoisePayload(_ typedPayload: Data, to peerID: PeerID) {
guard noiseService.hasSession(with: peerID) else {
// Lazy-handshake path: queue? For now, initiate handshake and drop
// No session yet - queue the payload SYNCHRONOUSLY before initiating handshake
// to prevent race where fast handshake completion drains empty queue
collectionsQueue.sync(flags: .barrier) {
if self.pendingNoisePayloadsAfterHandshake[peerID] == nil {
self.pendingNoisePayloadsAfterHandshake[peerID] = []
}
self.pendingNoisePayloadsAfterHandshake[peerID]?.append(typedPayload)
SecureLogger.debug("📥 Queued noise payload for \(peerID) pending handshake", category: .session)
}
initiateNoiseHandshake(with: peerID)
return
}
@@ -2874,13 +3053,19 @@ extension BLEService {
bleQueue.async { [weak self] in
guard let self = self else { return }
guard let state = self.peripherals[uuid], let ch = state.characteristic else { return }
var queueCopy: [PendingWrite] = []
self.collectionsQueue.sync {
queueCopy = self.pendingPeripheralWrites[uuid] ?? []
// Atomically take all pending items from the queue to avoid race conditions
// where new items could be enqueued between read and update
let itemsToSend: [PendingWrite] = self.collectionsQueue.sync(flags: .barrier) {
let items = self.pendingPeripheralWrites[uuid] ?? []
self.pendingPeripheralWrites[uuid] = nil
return items
}
guard !queueCopy.isEmpty else { return }
guard !itemsToSend.isEmpty else { return }
// Send as many as possible
var sent = 0
for item in queueCopy {
for item in itemsToSend {
if peripheral.canSendWriteWithoutResponse {
peripheral.writeValue(item.data, for: ch, type: .withoutResponse)
sent += 1
@@ -2888,23 +3073,66 @@ extension BLEService {
break
}
}
if sent > 0 {
// Re-enqueue any items that couldn't be sent (maintaining order)
let unsent = Array(itemsToSend.dropFirst(sent))
if !unsent.isEmpty {
self.collectionsQueue.async(flags: .barrier) {
var q = self.pendingPeripheralWrites[uuid] ?? []
if sent > 0 {
let toRemove = min(sent, q.count)
if toRemove > 0 {
q.removeFirst(toRemove)
}
self.pendingPeripheralWrites[uuid] = q.isEmpty ? nil : q
}
var existing = self.pendingPeripheralWrites[uuid] ?? []
// Prepend unsent items to maintain priority order
existing.insert(contentsOf: unsent, at: 0)
self.pendingPeripheralWrites[uuid] = existing
}
}
}
}
/// Periodically try to drain pending notifications as a backup mechanism
private func drainPendingNotificationsIfPossible() {
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self,
let characteristic = self.characteristic,
!self.pendingNotifications.isEmpty else { return }
let pending = self.pendingNotifications
self.pendingNotifications.removeAll()
var sentCount = 0
for (index, (data, centrals)) in pending.enumerated() {
let success: Bool
if let centrals = centrals {
success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: centrals) ?? false
} else {
success = self.peripheralManager?.updateValue(data, for: characteristic, onSubscribedCentrals: nil) ?? false
}
if !success {
// Re-queue this and all remaining items
let remaining = pending.dropFirst(index)
self.pendingNotifications.append(contentsOf: remaining)
break
} else {
sentCount += 1
}
}
if sentCount > 0 {
SecureLogger.debug("🔄 Periodic drain: sent \(sentCount) pending notifications", category: .session)
}
}
}
/// Periodically try to drain pending writes for all connected peripherals
private func drainAllPendingWrites() {
let uuids = collectionsQueue.sync { Array(pendingPeripheralWrites.keys) }
for uuid in uuids {
guard let state = peripherals[uuid], state.isConnected else { continue }
drainPendingWrites(for: state.peripheral)
}
}
// MARK: Application State Handlers (iOS)
#if os(iOS)
@objc private func appDidBecomeActive() {
isAppActive = true
@@ -3033,17 +3261,20 @@ extension BLEService {
}
private func sendPendingMessagesAfterHandshake(for peerID: PeerID) {
// Get and clear pending messages for this peer
// Atomically take all pending messages to process (prevents concurrent modification)
let pendingMessages = collectionsQueue.sync(flags: .barrier) { () -> [(content: String, messageID: String)]? in
let messages = pendingMessagesAfterHandshake[peerID]
pendingMessagesAfterHandshake.removeValue(forKey: peerID)
return messages
}
guard let messages = pendingMessages, !messages.isEmpty else { return }
SecureLogger.debug("📤 Sending \(messages.count) pending messages after handshake to \(peerID)", category: .session)
// Track failed messages for re-queuing
var failedMessages: [(content: String, messageID: String)] = []
// Send each pending message directly (we know session is established)
for (content, messageID) in messages {
do {
@@ -3051,6 +3282,7 @@ extension BLEService {
let privateMessage = PrivateMessagePacket(messageID: messageID, content: content)
guard let tlvData = privateMessage.encode() else {
SecureLogger.error("Failed to encode pending private message TLV")
failedMessages.append((content, messageID))
continue
}
@@ -3080,6 +3312,7 @@ extension BLEService {
SecureLogger.debug("✅ Sent pending message \(messageID) to \(peerID) after handshake", category: .session)
} catch {
SecureLogger.error("Failed to send pending message after handshake: \(error)")
failedMessages.append((content, messageID))
// Notify delegate of failure
notifyUI { [weak self] in
@@ -3087,6 +3320,19 @@ extension BLEService {
}
}
}
// Re-queue any failed messages for retry on next handshake
if !failedMessages.isEmpty {
collectionsQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
if self.pendingMessagesAfterHandshake[peerID] == nil {
self.pendingMessagesAfterHandshake[peerID] = []
}
// Prepend failed messages to maintain order
self.pendingMessagesAfterHandshake[peerID]?.insert(contentsOf: failedMessages, at: 0)
SecureLogger.warning("⚠️ Re-queued \(failedMessages.count) failed messages for \(peerID)", category: .session)
}
}
}
// MARK: Fragmentation (Required for messages > BLE MTU)
@@ -3235,7 +3481,8 @@ extension BLEService {
signature: nil,
ttl: packet.ttl,
version: fragmentVersion,
route: packet.route
route: packet.route,
isRSR: packet.isRSR
)
let workItem = DispatchWorkItem { [weak self] in
@@ -3426,9 +3673,16 @@ extension BLEService {
// Decode the original packet bytes we reassembled, so flags/compression are preserved
if var originalPacket = BinaryProtocol.decode(reassembled) {
SecureLogger.debug("✅ Reassembled packet id=\(String(format: "%016llx", fragU64)) type=\(originalPacket.type) bytes=\(reassembled.count)", category: .session)
originalPacket.ttl = 0
handleReceivedPacket(originalPacket, from: peerID)
// Reassembled packet validation
let innerSender = PeerID(hexData: originalPacket.senderID)
if !validatePacket(originalPacket, from: innerSender) {
// Cleanup below
} else {
SecureLogger.debug("✅ Reassembled packet id=\(String(format: "%016llx", fragU64)) type=\(originalPacket.type) bytes=\(reassembled.count)", category: .session)
originalPacket.ttl = 0
handleReceivedPacket(originalPacket, from: peerID)
}
} else {
SecureLogger.error("❌ Failed to decode reassembled packet (type=\(originalType), total=\(total))", category: .session)
}
@@ -3715,9 +3969,6 @@ extension BLEService {
claimedNickname: announcement.nickname
)
// Record this announce for lightweight rebroadcast buffer (exclude self)
// (recentAnnounces has been removed in the refactor)
// Notify UI on main thread
notifyUI { [weak self] in
guard let self = self else { return }
@@ -3836,16 +4087,13 @@ extension BLEService {
}
}
}
// If still not accepted and this is a sync-returned packet (TTL==0),
// accept with a generic nickname so history can be restored even for
// peers we haven't verified yet.
if !accepted && packet.ttl == 0 {
accepted = true
senderNickname = "anon" + String(peerID.id.prefix(4))
}
}
// Track broadcast messages for sync (treat nil or 0xFF..0xFF as broadcast)
guard accepted else {
SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))", category: .security)
return
}
let isBroadcastRecipient: Bool = {
guard let r = packet.recipientID else { return true }
return r.count == 8 && r.allSatisfy { $0 == 0xFF }
@@ -3854,11 +4102,6 @@ extension BLEService {
gossipSyncManager?.onPublicPacketSeen(packet)
}
guard accepted else {
SecureLogger.warning("🚫 Dropping public message from unverified or unknown peer \(peerID.id.prefix(8))", category: .security)
return
}
guard let content = String(data: packet.payload, encoding: .utf8) else {
SecureLogger.error("❌ Failed to decode message payload as UTF-8", category: .session)
return
@@ -3979,7 +4222,11 @@ extension BLEService {
initiateNoiseHandshake(with: peerID)
}
} catch {
SecureLogger.error("❌ Failed to decrypt message from \(peerID): \(error)")
// Decryption failed - clear the corrupted session and re-initiate handshake
// This handles cases where session state got out of sync (nonce mismatch, etc.)
SecureLogger.error("❌ Failed to decrypt message from \(peerID): \(error) - clearing session and re-initiating handshake")
noiseService.clearSession(for: peerID)
initiateNoiseHandshake(with: peerID)
}
}
@@ -4120,7 +4367,12 @@ extension BLEService {
if maintenanceCounter % 2 == 1 {
flushDirectedSpool()
}
// Periodically attempt to drain pending notifications and writes as backup
// in case callbacks are missed or delayed (every maintenance cycle = 5 seconds)
drainPendingNotificationsIfPossible()
drainAllPendingWrites()
// No rotating alias: nothing to refresh
// Reset counter to prevent overflow (every 60 seconds)
-11
View File
@@ -59,22 +59,11 @@ final class CommandProcessor {
weak var meshService: Transport?
private let identityManager: SecureIdentityStateManagerProtocol
/// Backward-compatible property for existing code
weak var chatViewModel: CommandContextProvider? {
get { contextProvider }
set { contextProvider = newValue }
}
init(contextProvider: CommandContextProvider? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
self.contextProvider = contextProvider
self.meshService = meshService
self.identityManager = identityManager
}
/// Backward-compatible initializer
convenience init(chatViewModel: ChatViewModel? = nil, meshService: Transport? = nil, identityManager: SecureIdentityStateManagerProtocol) {
self.init(contextProvider: chatViewModel, meshService: meshService, identityManager: identityManager)
}
/// Process a command string
@MainActor
@@ -31,9 +31,6 @@ final class FavoritesPersistenceService: ObservableObject {
@Published private(set) var favorites: [Data: FavoriteRelationship] = [:] // Noise pubkey -> relationship
@Published private(set) var mutualFavorites: Set<Data> = []
private let userDefaults = UserDefaults.standard
private var cancellables = Set<AnyCancellable>()
static let shared = FavoritesPersistenceService()
init(keychain: KeychainManagerProtocol = KeychainManager()) {
+24 -7
View File
@@ -11,6 +11,10 @@ final class MeshTopologyTracker {
// Last time we received an update from a node
private var lastSeen: [RoutingID: Date] = [:]
// Maximum age for topology claims to be considered fresh for routing
// Routes computed using stale topology can fail when the network has changed
private static let routeFreshnessThreshold: TimeInterval = 60 // 60 seconds
func reset() {
queue.sync(flags: .barrier) {
self.claims.removeAll()
@@ -55,25 +59,33 @@ final class MeshTopologyTracker {
if source == target { return [] } // Direct connection, no intermediate hops
return queue.sync {
let now = Date()
let freshnessDeadline = now.addingTimeInterval(-Self.routeFreshnessThreshold)
// BFS
var visited: Set<RoutingID> = [source]
// Queue stores paths: [Start, Hop1, Hop2, ..., Current]
var queuePaths: [[RoutingID]] = [[source]]
while !queuePaths.isEmpty {
let path = queuePaths.removeFirst()
// Limit path length (path contains source + maxHops + target) -> maxHops intermediate
// If maxHops = 10, max edges = 11, max nodes = 12.
if path.count > maxHops + 1 { continue }
guard let last = path.last else { continue }
// Get neighbors that 'last' claims to see
guard let neighbors = claims[last] else { continue }
// Check if 'last' node's topology info is fresh
guard let lastSeenTime = lastSeen[last], lastSeenTime > freshnessDeadline else {
continue // Skip stale nodes
}
for neighbor in neighbors {
if visited.contains(neighbor) { continue }
// CONFIRMED EDGE CHECK:
// 'last' claims 'neighbor' (checked above)
// Does 'neighbor' claim 'last'?
@@ -81,16 +93,21 @@ final class MeshTopologyTracker {
neighborClaims.contains(last) else {
continue
}
// Check if 'neighbor' node's topology info is fresh
guard let neighborSeenTime = lastSeen[neighbor], neighborSeenTime > freshnessDeadline else {
continue // Skip edges to stale nodes
}
var nextPath = path
nextPath.append(neighbor)
if neighbor == target {
// Return only intermediate hops
// Path: [Source, I1, I2, Target] -> [I1, I2]
return Array(nextPath.dropFirst().dropLast())
}
visited.insert(neighbor)
queuePaths.append(nextPath)
}
+71 -31
View File
@@ -5,7 +5,20 @@ import Foundation
@MainActor
final class MessageRouter {
private let transports: [Transport]
private var outbox: [PeerID: [(content: String, nickname: String, messageID: String)]] = [:] // peerID -> queued messages
// Outbox entry with timestamp for TTL-based eviction
private struct QueuedMessage {
let content: String
let nickname: String
let messageID: String
let timestamp: Date
}
private var outbox: [PeerID: [QueuedMessage]] = [:]
// Outbox limits to prevent unbounded memory growth
private static let maxMessagesPerPeer = 100
private static let messageTTLSeconds: TimeInterval = 24 * 60 * 60 // 24 hours
init(transports: [Transport]) {
self.transports = transports
@@ -34,52 +47,60 @@ final class MessageRouter {
}
}
// MARK: - Transport Selection
private func reachableTransport(for peerID: PeerID) -> Transport? {
transports.first { $0.isPeerReachable(peerID) }
}
private func connectedTransport(for peerID: PeerID) -> Transport? {
transports.first { $0.isPeerConnected(peerID) }
}
// MARK: - Message Sending
func sendPrivate(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
// Try to find a reachable transport
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
if let transport = reachableTransport(for: peerID) {
SecureLogger.debug("Routing PM via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: recipientNickname, messageID: messageID)
} else {
// Queue for later
// Queue for later with timestamp for TTL tracking
if outbox[peerID] == nil { outbox[peerID] = [] }
outbox[peerID]?.append((content, recipientNickname, messageID))
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))", category: .session)
let message = QueuedMessage(content: content, nickname: recipientNickname, messageID: messageID, timestamp: Date())
outbox[peerID]?.append(message)
// Enforce per-peer size limit with FIFO eviction
if let count = outbox[peerID]?.count, count > Self.maxMessagesPerPeer {
let evicted = outbox[peerID]?.removeFirst()
SecureLogger.warning("📤 Outbox overflow for \(peerID.id.prefix(8))… - evicted oldest message: \(evicted?.messageID.prefix(8) ?? "?")", category: .session)
}
SecureLogger.debug("Queued PM for \(peerID.id.prefix(8))… (no reachable transport) id=\(messageID.prefix(8))… queue=\(outbox[peerID]?.count ?? 0)", category: .session)
}
}
func sendReadReceipt(_ receipt: ReadReceipt, to peerID: PeerID) {
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
if let transport = reachableTransport(for: peerID) {
SecureLogger.debug("Routing READ ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(receipt.originalMessageID.prefix(8))", category: .session)
transport.sendReadReceipt(receipt, to: peerID)
} else if !transports.isEmpty {
// Fallback to last transport (usually Nostr) if neither is explicitly reachable?
// Or better: just try the first one that supports it?
// Existing logic preferred mesh, then nostr.
// If neither reachable, existing logic queued it (via mesh usually) or sent via nostr.
// Let's stick to "try reachable". If none, maybe pick the first one to queue?
// Actually, for READ receipts, we might want to just fire-and-forget on the "best effort" transport.
// But let's stick to the reachable check.
SecureLogger.debug("No reachable transport for READ ack to \(peerID.id.prefix(8))", category: .session)
}
}
func sendDeliveryAck(_ messageID: String, to peerID: PeerID) {
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
if let transport = reachableTransport(for: peerID) {
SecureLogger.debug("Routing DELIVERED ack via \(type(of: transport)) to \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendDeliveryAck(for: messageID, to: peerID)
}
}
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
if let transport = transports.first(where: { $0.isPeerConnected(peerID) }) {
if let transport = connectedTransport(for: peerID) {
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else if let transport = reachableTransport(for: peerID) {
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
transport.sendFavoriteNotification(to: peerID, isFavorite: isFavorite)
} else {
// Fallback: try all? or just the last one?
// Old logic: if mesh connected, mesh. Else nostr.
// Note: NostrTransport.isPeerReachable now returns true if mapped.
// If not mapped, we can't send via Nostr anyway.
}
}
@@ -88,17 +109,25 @@ final class MessageRouter {
func flushOutbox(for peerID: PeerID) {
guard let queued = outbox[peerID], !queued.isEmpty else { return }
SecureLogger.debug("Flushing outbox for \(peerID.id.prefix(8))… count=\(queued.count)", category: .session)
var remaining: [(content: String, nickname: String, messageID: String)] = []
for (content, nickname, messageID) in queued {
if let transport = transports.first(where: { $0.isPeerReachable(peerID) }) {
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(content, to: peerID, recipientNickname: nickname, messageID: messageID)
let now = Date()
var remaining: [QueuedMessage] = []
for message in queued {
// Skip expired messages (TTL exceeded)
if now.timeIntervalSince(message.timestamp) > Self.messageTTLSeconds {
SecureLogger.debug("⏰ Expired queued message for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))… (age: \(Int(now.timeIntervalSince(message.timestamp)))s)", category: .session)
continue
}
if let transport = reachableTransport(for: peerID) {
SecureLogger.debug("Outbox -> \(type(of: transport)) for \(peerID.id.prefix(8))… id=\(message.messageID.prefix(8))", category: .session)
transport.sendPrivateMessage(message.content, to: peerID, recipientNickname: message.nickname, messageID: message.messageID)
} else {
remaining.append((content, nickname, messageID))
remaining.append(message)
}
}
if remaining.isEmpty {
outbox.removeValue(forKey: peerID)
} else {
@@ -109,4 +138,15 @@ final class MessageRouter {
func flushAllOutbox() {
for key in Array(outbox.keys) { flushOutbox(for: key) }
}
/// Periodically clean up expired messages from all outboxes
func cleanupExpiredMessages() {
let now = Date()
for peerID in Array(outbox.keys) {
outbox[peerID]?.removeAll { now.timeIntervalSince($0.timestamp) > Self.messageTTLSeconds }
if outbox[peerID]?.isEmpty == true {
outbox.removeValue(forKey: peerID)
}
}
}
}
@@ -616,6 +616,17 @@ final class NoiseEncryptionService {
}
rateLimiter.resetAll()
}
/// Clear session for a specific peer (e.g., on decryption failure to allow re-handshake)
func clearSession(for peerID: PeerID) {
sessionManager.removeSession(for: peerID)
serviceQueue.sync(flags: .barrier) {
if let fingerprint = peerFingerprints.removeValue(forKey: peerID) {
fingerprintToPeerID.removeValue(forKey: fingerprint)
}
}
SecureLogger.debug("🔓 Cleared Noise session for \(peerID)", category: .session)
}
// MARK: - Private Helpers
+53 -97
View File
@@ -118,32 +118,15 @@ final class NostrTransport: Transport, @unchecked Sendable {
func sendPrivateMessage(_ content: String, to peerID: PeerID, recipientNickname: String, messageID: String) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… for peerID \(peerID.id.prefix(8))… id=\(messageID.prefix(8))", category: .session)
// Convert recipient npub -> hex (x-only)
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else {
SecureLogger.error("NostrTransport: recipient key not npub (hrp=\(hrp))", category: .session)
return
}
recipientHex = data.hexEncodedString()
} catch {
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
return
}
guard let recipientNpub = resolveRecipientNpub(for: peerID),
let recipientHex = npubToHex(recipientNpub),
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing PM to \(recipientNpub.prefix(16))… id=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed PM packet", category: .session)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for PM", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending PM giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
}
}
@@ -158,58 +141,31 @@ final class NostrTransport: Transport, @unchecked Sendable {
func sendFavoriteNotification(to peerID: PeerID, isFavorite: Bool) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
guard let recipientNpub = resolveRecipientNpub(for: peerID),
let recipientHex = npubToHex(recipientNpub),
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
let content = isFavorite ? "[FAVORITED]:\(senderIdentity.npub)" : "[UNFAVORITED]:\(senderIdentity.npub)"
SecureLogger.debug("NostrTransport: preparing FAVORITE(\(isFavorite)) to \(recipientNpub.prefix(16))", category: .session)
// Convert recipient npub -> hex
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { return }
recipientHex = data.hexEncodedString()
} catch {
SecureLogger.error("NostrTransport: failed to decode recipient npub for favorite notification: \(error.localizedDescription)", category: .session)
return
}
guard let embedded = NostrEmbeddedBitChat.encodePMForNostr(content: content, messageID: UUID().uuidString, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed favorite notification", category: .session)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for favorite notification", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending favorite giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: senderIdentity)
}
}
func sendBroadcastAnnounce() { /* no-op for Nostr */ }
func sendDeliveryAck(for messageID: String, to peerID: PeerID) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: peerID) else { return }
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing DELIVERED ack for id=\(messageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { return }
recipientHex = data.hexEncodedString()
} catch {
SecureLogger.error("NostrTransport: failed to decode recipient npub for delivery ack: \(error.localizedDescription)", category: .session)
return
}
guard let recipientNpub = resolveRecipientNpub(for: peerID),
let recipientHex = npubToHex(recipientNpub),
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing DELIVERED ack id=\(messageID.prefix(8))", category: .session)
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .delivered, messageID: messageID, recipientPeerID: peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed DELIVERED ack", category: .session)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for DELIVERED ack", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending DELIVERED ack giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
}
}
}
@@ -221,21 +177,17 @@ extension NostrTransport {
// MARK: Geohash ACK helpers
func sendDeliveryAckGeohash(for messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in
SecureLogger.debug("GeoDM: send DELIVERED -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.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 event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
}
}
func sendReadReceiptGeohash(_ messageID: String, toRecipientHex recipientHex: String, from identity: NostrIdentity) {
Task { @MainActor in
SecureLogger.debug("GeoDM: send READ -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.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 event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else { return }
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
}
}
@@ -243,19 +195,12 @@ extension NostrTransport {
func sendPrivateMessageGeohash(content: String, toRecipientHex recipientHex: String, from identity: NostrIdentity, messageID: String) {
Task { @MainActor in
guard !recipientHex.isEmpty else { return }
SecureLogger.debug("GeoDM: send PM -> recip=\(recipientHex.prefix(8))… mid=\(messageID.prefix(8))… from=\(identity.publicKeyHex.prefix(8))", category: .session)
// Build embedded BitChat packet without recipient peer ID
SecureLogger.debug("GeoDM: send PM mid=\(messageID.prefix(8))", category: .session)
guard let embedded = NostrEmbeddedBitChat.encodePMForNostrNoRecipient(content: content, messageID: messageID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed geohash PM packet", category: .session)
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: embedded, recipientPubkey: recipientHex, senderIdentity: identity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for geohash PM", category: .session)
return
}
SecureLogger.debug("NostrTransport: sending geohash PM giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.registerPendingGiftWrap(id: event.id)
NostrRelayManager.shared.sendEvent(event)
sendWrappedMessage(content: embedded, recipientHex: recipientHex, senderIdentity: identity, registerPending: true)
}
}
}
@@ -263,6 +208,32 @@ extension NostrTransport {
// MARK: - Private Helpers
extension NostrTransport {
/// Converts npub bech32 string to hex pubkey
@MainActor
private func npubToHex(_ npub: String) -> String? {
do {
let (hrp, data) = try Bech32.decode(npub)
guard hrp == "npub" else { return nil }
return data.hexEncodedString()
} catch {
SecureLogger.error("NostrTransport: failed to decode npub -> hex: \(error)", category: .session)
return nil
}
}
/// Creates and sends a gift-wrapped private message event
@MainActor
private func sendWrappedMessage(content: String, recipientHex: String, senderIdentity: NostrIdentity, registerPending: Bool = false) {
guard let event = try? NostrProtocol.createPrivateMessage(content: content, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event", category: .session)
return
}
if registerPending {
NostrRelayManager.registerPendingGiftWrap(id: event.id)
}
NostrRelayManager.shared.sendEvent(event)
}
/// Must be called within a barrier on `queue`
private func processReadQueueIfNeeded() {
guard !isSendingReadAcks else { return }
@@ -275,31 +246,16 @@ extension NostrTransport {
/// Sends a single read ack item (called after extraction from queue within barrier)
private func sendReadAckItem(_ item: QueuedRead) {
Task { @MainActor in
guard let recipientNpub = resolveRecipientNpub(for: item.peerID) else { scheduleNextReadAck(); return }
guard let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { scheduleNextReadAck(); return }
SecureLogger.debug("NostrTransport: preparing READ ack for id=\(item.receipt.originalMessageID.prefix(8))… to \(recipientNpub.prefix(16))", category: .session)
// Convert recipient npub -> hex
let recipientHex: String
do {
let (hrp, data) = try Bech32.decode(recipientNpub)
guard hrp == "npub" else { scheduleNextReadAck(); return }
recipientHex = data.hexEncodedString()
} catch {
SecureLogger.error("NostrTransport: failed to decode recipient npub for read ack: \(error.localizedDescription)", category: .session)
scheduleNextReadAck()
return
}
defer { scheduleNextReadAck() }
guard let recipientNpub = resolveRecipientNpub(for: item.peerID),
let recipientHex = npubToHex(recipientNpub),
let senderIdentity = try? idBridge.getCurrentNostrIdentity() else { return }
SecureLogger.debug("NostrTransport: preparing READ ack id=\(item.receipt.originalMessageID.prefix(8))", category: .session)
guard let ack = NostrEmbeddedBitChat.encodeAckForNostr(type: .readReceipt, messageID: item.receipt.originalMessageID, recipientPeerID: item.peerID, senderPeerID: senderPeerID) else {
SecureLogger.error("NostrTransport: failed to embed READ ack", category: .session)
scheduleNextReadAck(); return
return
}
guard let event = try? NostrProtocol.createPrivateMessage(content: ack, recipientPubkey: recipientHex, senderIdentity: senderIdentity) else {
SecureLogger.error("NostrTransport: failed to build Nostr event for READ ack", category: .session)
scheduleNextReadAck(); return
}
SecureLogger.debug("NostrTransport: sending READ ack giftWrap id=\(event.id.prefix(16))", category: .session)
NostrRelayManager.shared.sendEvent(event)
scheduleNextReadAck()
sendWrappedMessage(content: ack, recipientHex: recipientHex, senderIdentity: senderIdentity)
}
}
+20 -5
View File
@@ -96,11 +96,12 @@ enum TransportConfig {
// Keep scanning fully ON when we saw traffic very recently
static let bleRecentTrafficForceScanSeconds: TimeInterval = 10.0
static let bleThreadSleepWriteShortDelaySeconds: TimeInterval = 0.05
static let bleExpectedWritePerFragmentMs: Int = 8
static let bleExpectedWriteMaxMs: Int = 2000
// Faster fragment pacing; use slightly tighter spacing for directed trains
static let bleFragmentSpacingMs: Int = 5
static let bleFragmentSpacingDirectedMs: Int = 4
static let bleExpectedWritePerFragmentMs: Int = 20
static let bleExpectedWriteMaxMs: Int = 5000
// Fragment pacing: Conservative spacing to prevent BLE buffer overflow
// Aggressive pacing causes packet loss; needs 25-30ms between fragments for reliable delivery
static let bleFragmentSpacingMs: Int = 30
static let bleFragmentSpacingDirectedMs: Int = 25
static let bleAnnounceIntervalSeconds: TimeInterval = 4.0
static let bleDutyOnDurationDense: TimeInterval = 3.0
static let bleDutyOffDurationDense: TimeInterval = 15.0
@@ -211,4 +212,18 @@ enum TransportConfig {
static let uiShareExtensionDismissDelaySeconds: TimeInterval = 2.0
static let uiShareAcceptWindowSeconds: TimeInterval = 30.0
static let uiMigrationCutoffSeconds: TimeInterval = 24 * 60 * 60
// Gossip Sync Configuration
static let syncSeenCapacity: Int = 1000
static let syncGCSMaxBytes: Int = 400
static let syncGCSTargetFpr: Double = 0.01
static let syncMaxMessageAgeSeconds: TimeInterval = 900
static let syncMaintenanceIntervalSeconds: TimeInterval = 30.0
static let syncStalePeerCleanupIntervalSeconds: TimeInterval = 60.0
static let syncStalePeerTimeoutSeconds: TimeInterval = 60.0
static let syncFragmentCapacity: Int = 600
static let syncFileTransferCapacity: Int = 200
static let syncFragmentIntervalSeconds: TimeInterval = 30.0
static let syncFileTransferIntervalSeconds: TimeInterval = 60.0
static let syncMessageIntervalSeconds: TimeInterval = 15.0
}
+28 -2
View File
@@ -1,4 +1,5 @@
import Foundation
import BitLogger
// Gossip-based sync manager using on-demand GCS filters
final class GossipSyncManager {
@@ -6,6 +7,7 @@ final class GossipSyncManager {
func sendPacket(_ packet: BitchatPacket)
func sendPacket(to peerID: PeerID, packet: BitchatPacket)
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket
func getConnectedPeers() -> [PeerID]
}
private struct PacketStore {
@@ -74,6 +76,7 @@ final class GossipSyncManager {
private let myPeerID: PeerID
private let config: Config
private let requestSyncManager: RequestSyncManager
weak var delegate: Delegate?
// Storage: broadcast packets by type, and latest announce per sender
@@ -88,9 +91,10 @@ final class GossipSyncManager {
private var lastStalePeerCleanup: Date = .distantPast
private var syncSchedules: [SyncSchedule] = []
init(myPeerID: PeerID, config: Config = Config()) {
init(myPeerID: PeerID, config: Config = Config(), requestSyncManager: RequestSyncManager) {
self.myPeerID = myPeerID
self.config = config
self.requestSyncManager = requestSyncManager
var schedules: [SyncSchedule] = []
if config.seenCapacity > 0 && config.messageSyncIntervalSeconds > 0 {
schedules.append(SyncSchedule(types: .publicMessages, interval: config.messageSyncIntervalSeconds, lastSent: .distantPast))
@@ -202,6 +206,19 @@ final class GossipSyncManager {
}
}
private func sendPeriodicSync(for types: SyncTypeFlags) {
// Unicast sync to connected peers to allow RSR attribution
if let connectedPeers = delegate?.getConnectedPeers(), !connectedPeers.isEmpty {
SecureLogger.debug("Sending periodic sync to \(connectedPeers.count) connected peers", category: .sync)
for peerID in connectedPeers {
sendRequestSync(to: peerID, types: types)
}
} else {
// Fallback to broadcast (discovery phase)
sendRequestSync(for: types)
}
}
private func sendRequestSync(for types: SyncTypeFlags) {
let payload = buildGcsPayload(for: types)
let pkt = BitchatPacket(
@@ -218,6 +235,9 @@ final class GossipSyncManager {
}
private func sendRequestSync(to peerID: PeerID, types: SyncTypeFlags) {
// Register the request for RSR validation
requestSyncManager.registerRequest(to: peerID)
let payload = buildGcsPayload(for: types)
var recipient = Data()
var temp = peerID.id
@@ -262,6 +282,7 @@ final class GossipSyncManager {
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
@@ -274,6 +295,7 @@ final class GossipSyncManager {
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
@@ -286,6 +308,7 @@ final class GossipSyncManager {
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
@@ -298,6 +321,7 @@ final class GossipSyncManager {
if !mightContain(idBytes) {
var toSend = pkt
toSend.ttl = 0
toSend.isRSR = true // Mark as solicited response
delegate?.sendPacket(to: peerID, packet: toSend)
}
}
@@ -366,11 +390,13 @@ final class GossipSyncManager {
private func performPeriodicMaintenance(now: Date = Date()) {
cleanupExpiredMessages()
cleanupStaleAnnouncementsIfNeeded(now: now)
requestSyncManager.cleanup() // Cleanup expired sync requests
for index in syncSchedules.indices {
guard syncSchedules[index].interval > 0 else { continue }
if syncSchedules[index].lastSent == .distantPast || now.timeIntervalSince(syncSchedules[index].lastSent) >= syncSchedules[index].interval {
syncSchedules[index].lastSent = now
sendRequestSync(for: syncSchedules[index].types)
sendPeriodicSync(for: syncSchedules[index].types)
}
}
}
+74
View File
@@ -0,0 +1,74 @@
//
// RequestSyncManager.swift
// bitchat
//
// This is free and unencumbered software released into the public domain.
// For more information, see <https://unlicense.org>
//
import Foundation
import BitLogger
/// Manages outgoing sync requests and validates incoming responses.
///
/// Allows attributing RSR (Request-Sync Response) packets to specific peers
/// that we have actively requested sync from.
final class RequestSyncManager {
private let queue = DispatchQueue(label: "request.sync.manager", attributes: .concurrent)
private var pendingRequests: [PeerID: TimeInterval] = [:]
// Allow responses for 30s after request
private let responseWindow: TimeInterval = 30.0
/// Register that we are sending a sync request to a peer.
/// - Parameter peerID: The peer we are requesting sync from
func registerRequest(to peerID: PeerID) {
let now = Date().timeIntervalSince1970
queue.async(flags: .barrier) {
SecureLogger.debug("Registering sync request to \(peerID.id.prefix(8))", category: .sync)
self.pendingRequests[peerID] = now
}
}
/// Check if a packet from a peer is a valid response to a sync request.
///
/// - Parameters:
/// - peerID: The sender of the packet
/// - isRSR: Whether the packet is marked as a Request-Sync Response
/// - Returns: true if we have a pending request for this peer and the window is open
func isValidResponse(from peerID: PeerID, isRSR: Bool) -> Bool {
guard isRSR else { return false }
return queue.sync {
guard let requestTime = pendingRequests[peerID] else {
SecureLogger.warning("Received unsolicited RSR packet from \(peerID.id.prefix(8))", category: .security)
return false
}
let now = Date().timeIntervalSince1970
if now - requestTime > responseWindow {
SecureLogger.warning("Received RSR packet from \(peerID.id.prefix(8))… outside of response window", category: .security)
// We don't remove here because we might receive multiple packets for one request
return false
}
return true
}
}
/// Periodic cleanup of expired requests
func cleanup() {
let now = Date().timeIntervalSince1970
queue.async(flags: .barrier) {
let originalCount = self.pendingRequests.count
self.pendingRequests = self.pendingRequests.filter { _, timestamp in
now - timestamp <= self.responseWindow
}
let removed = originalCount - self.pendingRequests.count
if removed > 0 {
SecureLogger.debug("Cleaned up \(removed) expired sync requests", category: .sync)
}
}
}
}
+1 -59
View File
@@ -279,8 +279,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
var geoNicknames: [String: String] = [:] // pubkeyHex(lowercased) -> nickname
// Show Tor status once per app launch
var torStatusAnnounced = false
private var torProgressCancellable: AnyCancellable?
private var lastTorProgressAnnounced = -1
// Track whether a Tor restart is pending so we only announce
// "tor restarted" after an actual restart, not the first launch.
var torRestartPending: Bool = false
@@ -323,8 +321,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
)
// Channel activity tracking for background nudges
var lastPublicActivityAt: [String: Date] = [:] // channelKey -> last activity time
private var lastPublicActivityNotifyAt: [String: Date] = [:]
private let channelInactivityThreshold: TimeInterval = TransportConfig.uiChannelInactivityThresholdSeconds
// Geohash participant tracker
let participantTracker = GeohashParticipantTracker(activityCutoff: -TransportConfig.uiRecentCutoffFiveMinutesSeconds)
// Participants who indicated they teleported (by tag in their events)
@@ -448,7 +444,7 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
self.deduplicationService = MessageDeduplicationService()
// Wire up dependencies
self.commandProcessor.chatViewModel = self
self.commandProcessor.contextProvider = self
self.participantTracker.configure(context: self)
// Subscribe to privateChatManager changes to trigger UI updates
@@ -503,8 +499,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
addGeohashOnlySystemMessage(
String(localized: "system.tor.starting", comment: "System message when Tor is starting")
)
// Suppress incremental Tor progress messages
torProgressCancellable = nil
} else if !TorManager.shared.torEnforced && !torStatusAnnounced {
torStatusAnnounced = true
addGeohashOnlySystemMessage(
@@ -631,8 +625,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
object: nil
)
// Listen for delivery acknowledgments
// When app becomes active, send read receipts for visible messages
#if os(macOS)
NotificationCenter.default.addObserver(
@@ -1468,56 +1460,6 @@ final class ChatViewModel: ObservableObject, BitchatDelegate, CommandContextProv
selectedPrivateChatFingerprint = nil
}
// MARK: - Nostr Message Handling
@MainActor
@objc private func handleNostrMessage(_ notification: Notification) {
guard let message = notification.userInfo?["message"] as? BitchatMessage else { return }
// Store the Nostr pubkey if provided (for messages from unknown senders)
if let nostrPubkey = notification.userInfo?["nostrPubkey"] as? String,
let senderPeerID = message.senderPeerID {
// Store mapping for read receipts
nostrKeyMapping[senderPeerID] = nostrPubkey
}
// Process the Nostr message through the same flow as Bluetooth messages
didReceiveMessage(message)
}
@objc private func handleDeliveryAcknowledgment(_ notification: Notification) {
guard let messageId = notification.userInfo?["messageId"] as? String else { return }
// Update the delivery status for the message
if let index = messages.firstIndex(where: { $0.id == messageId }) {
// Update delivery status to delivered
messages[index].deliveryStatus = DeliveryStatus.delivered(to: "nostr", at: Date())
// Schedule UI update for delivery status
// UI will update automatically
}
// Also update in private chats if it's a private message
for (peerID, chatMessages) in privateChats {
if let index = chatMessages.firstIndex(where: { $0.id == messageId }) {
privateChats[peerID]?[index].deliveryStatus = DeliveryStatus.delivered(to: "nostr", at: Date())
// UI will update automatically
break
}
}
}
@objc private func handleNostrReadReceipt(_ notification: Notification) {
guard let receipt = notification.userInfo?["receipt"] as? ReadReceipt else { return }
SecureLogger.info("📖 Handling read receipt for message \(receipt.originalMessageID) from Nostr", category: .session)
// Process the read receipt through the same flow as Bluetooth read receipts
didReceiveReadReceipt(receipt)
}
@MainActor
@objc private func handlePeerStatusUpdate(_ notification: Notification) {
// Update private chat peer if needed when peer status changes
+14 -2
View File
@@ -15,10 +15,22 @@ struct PaymentChipView: View {
enum PaymentType {
case cashu(String)
case lightning(String)
private static let cashuAllowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
private static func cashuURL(from link: String) -> URL? {
if let url = URL(string: link), url.scheme != nil {
return url
}
let enc = link.addingPercentEncoding(withAllowedCharacters: cashuAllowedCharacters) ?? link
return URL(string: "cashu:\(enc)")
}
var url: URL? {
switch self {
case .cashu(let link), .lightning(let link):
case .cashu(let link):
return Self.cashuURL(from: link)
case .lightning(let link):
return URL(string: link)
}
}
+3 -68
View File
@@ -40,7 +40,6 @@ struct ContentView: View {
@Environment(\.colorScheme) var colorScheme
@Environment(\.dismiss) private var dismiss
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
@State private var showPeerList = false
@State private var showSidebar = false
@State private var showAppInfo = false
@State private var showMessageActions = false
@@ -57,7 +56,6 @@ struct ContentView: View {
@State private var expandedMessageIDs: Set<String> = []
@State private var showLocationNotes = false
@State private var notesGeohash: String? = nil
@State private var sheetNotesCount: Int = 0
@State private var imagePreviewURL: URL? = nil
@State private var recordingAlertMessage: String = ""
@State private var showRecordingAlert = false
@@ -1181,19 +1179,6 @@ struct ContentView: View {
)
}
// Split a name into base and a '#abcd' suffix if present
private func splitNameSuffix(_ name: String) -> (base: String, suffix: String) {
guard name.count >= 5 else { return (name, "") }
let suffix = String(name.suffix(5))
if suffix.first == "#", suffix.dropFirst().allSatisfy({ c in
("0"..."9").contains(String(c)) || ("a"..."f").contains(String(c)) || ("A"..."F").contains(String(c))
}) {
let base = String(name.dropLast(5))
return (base, suffix)
}
return (name, "")
}
// Compute channel-aware people count and color for toolbar (cross-platform)
private func channelPeopleCountAndColor() -> (Int, Color) {
switch locationManager.selectedChannel {
@@ -1308,8 +1293,8 @@ struct ContentView: View {
// Bookmark toggle (geochats): to the left of #geohash
if case .location(let ch) = locationManager.selectedChannel {
Button(action: { GeohashBookmarksStore.shared.toggle(ch.geohash) }) {
Image(systemName: GeohashBookmarksStore.shared.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
Button(action: { bookmarks.toggle(ch.geohash) }) {
Image(systemName: bookmarks.isBookmarked(ch.geohash) ? "bookmark.fill" : "bookmark")
.font(.bitchatSystem(size: 12))
}
.buttonStyle(.plain)
@@ -1567,7 +1552,7 @@ private extension ContentView {
} else if let media = mediaAttachment(for: message) {
mediaMessageRow(message: message, media: media)
} else {
textMessageRow(message)
TextMessageView(message: message, expandedMessageIDs: $expandedMessageIDs)
}
}
@@ -1631,56 +1616,6 @@ private extension ContentView {
.padding(.vertical, 4)
}
@ViewBuilder
private func textMessageRow(_ message: BitchatMessage) -> some View {
let cashuTokens = message.content.extractCashuLinks()
let lightningLinks = message.content.extractLightningLinks()
let isLong = (message.content.count > TransportConfig.uiLongMessageLengthThreshold || message.content.hasVeryLongToken(threshold: TransportConfig.uiVeryLongTokenThreshold)) && cashuTokens.isEmpty
let isExpanded = expandedMessageIDs.contains(message.id)
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top, spacing: 0) {
Text(viewModel.formatMessageAsText(message, colorScheme: colorScheme))
.fixedSize(horizontal: false, vertical: true)
.lineLimit(isLong && !isExpanded ? TransportConfig.uiLongMessageLineLimit : nil)
.frame(maxWidth: .infinity, alignment: .leading)
if message.isPrivate && message.sender == viewModel.nickname,
let status = message.deliveryStatus {
DeliveryStatusView(status: status)
.padding(.leading, 4)
}
}
if isLong && cashuTokens.isEmpty {
let labelKey = isExpanded ? LocalizedStringKey("content.message.show_less") : LocalizedStringKey("content.message.show_more")
Button(labelKey) {
if isExpanded { expandedMessageIDs.remove(message.id) }
else { expandedMessageIDs.insert(message.id) }
}
.font(.bitchatSystem(size: 11, weight: .medium, design: .monospaced))
.foregroundColor(Color.blue)
.padding(.top, 4)
}
if !lightningLinks.isEmpty || !cashuTokens.isEmpty {
HStack(spacing: 8) {
ForEach(Array(lightningLinks.prefix(3)), id: \.self) { link in
PaymentChipView(paymentType: .lightning(link))
}
ForEach(Array(cashuTokens.prefix(3)), id: \.self) { token in
let enc = token.addingPercentEncoding(withAllowedCharacters: .alphanumerics.union(CharacterSet(charactersIn: "-_"))) ?? token
let urlStr = "cashu:\(enc)"
PaymentChipView(paymentType: .cashu(urlStr))
}
}
.padding(.top, 6)
.padding(.leading, 2)
}
}
}
private func expandWindow(ifNeededFor message: BitchatMessage,
allMessages: [BitchatMessage],
privatePeer: PeerID?,
+4 -19
View File
@@ -5,21 +5,6 @@
import Foundation
private enum RegexCache {
static let cashu: NSRegularExpression = {
try! NSRegularExpression(pattern: "\\bcashu[AB][A-Za-z0-9._-]{40,}\\b", options: [])
}()
static let lightningScheme: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blightning:[^\\s]+", options: [])
}()
static let bolt11: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\bln(bc|tb|bcrt)[0-9][a-z0-9]{50,}\\b", options: [])
}()
static let lnurl: NSRegularExpression = {
try! NSRegularExpression(pattern: "(?i)\\blnurl1[a-z0-9]{20,}\\b", options: [])
}()
}
extension String {
// Detect if there is an extremely long token (no whitespace/newlines) that could break layout
func hasVeryLongToken(threshold: Int) -> Bool {
@@ -38,7 +23,7 @@ extension String {
// Extract up to `max` Cashu tokens (cashuA/cashuB). Allow dot '.' and shorter lengths.
func extractCashuLinks(max: Int = 3) -> [String] {
let regex = RegexCache.cashu
let regex = MessageFormattingEngine.Patterns.cashu
let ns = self as NSString
let range = NSRange(location: 0, length: ns.length)
var found: [String] = []
@@ -59,19 +44,19 @@ extension String {
let ns = self as NSString
let full = NSRange(location: 0, length: ns.length)
// lightning: scheme
for m in RegexCache.lightningScheme.matches(in: self, range: full) {
for m in MessageFormattingEngine.Patterns.lightningScheme.matches(in: self, range: full) {
let s = ns.substring(with: m.range(at: 0))
results.append(s)
if results.count >= max { return results }
}
// BOLT11
for m in RegexCache.bolt11.matches(in: self, range: full) {
for m in MessageFormattingEngine.Patterns.bolt11.matches(in: self, range: full) {
let s = ns.substring(with: m.range(at: 0))
results.append("lightning:\(s)")
if results.count >= max { return results }
}
// LNURL bech32
for m in RegexCache.lnurl.matches(in: self, range: full) {
for m in MessageFormattingEngine.Patterns.lnurl.matches(in: self, range: full) {
let s = ns.substring(with: m.range(at: 0))
results.append("lightning:\(s)")
if results.count >= max { return results }
+106
View File
@@ -0,0 +1,106 @@
//
// BLEServiceCoreTests.swift
// bitchatTests
//
// Focused BLEService tests for packet handling behavior.
//
import Testing
import Foundation
import CoreBluetooth
@testable import bitchat
struct BLEServiceCoreTests {
@Test
func duplicatePacket_isDeduped() async {
let ble = makeService()
let delegate = PublicCaptureDelegate()
ble.delegate = delegate
let sender = PeerID(str: "1122334455667788")
let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
let packet = makePublicPacket(content: "Hello", sender: sender, timestamp: timestamp)
ble._test_handlePacket(packet, fromPeerID: sender)
ble._test_handlePacket(packet, fromPeerID: sender)
_ = await TestHelpers.waitUntil({ delegate.publicMessagesSnapshot().count == 1 },
timeout: TestConstants.shortTimeout)
let messages = delegate.publicMessagesSnapshot()
#expect(messages.count == 1)
#expect(messages.first?.content == "Hello")
}
@Test
func staleBroadcast_isIgnored() async {
let ble = makeService()
let delegate = PublicCaptureDelegate()
ble.delegate = delegate
let sender = PeerID(str: "A1B2C3D4E5F60708")
let oldTimestamp = UInt64(Date().addingTimeInterval(-901).timeIntervalSince1970 * 1000)
let packet = makePublicPacket(content: "Old", sender: sender, timestamp: oldTimestamp)
ble._test_handlePacket(packet, fromPeerID: sender)
let didReceive = await TestHelpers.waitUntil({ !delegate.publicMessagesSnapshot().isEmpty }, timeout: 0.3)
#expect(!didReceive)
#expect(delegate.publicMessagesSnapshot().isEmpty)
}
}
private func makeService() -> BLEService {
let keychain = MockKeychain()
let identityManager = MockIdentityManager(keychain)
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
return BLEService(keychain: keychain, idBridge: idBridge, identityManager: identityManager)
}
private func makePublicPacket(content: String, sender: PeerID, timestamp: UInt64) -> BitchatPacket {
BitchatPacket(
type: MessageType.message.rawValue,
senderID: Data(hexString: sender.id) ?? Data(),
recipientID: nil,
timestamp: timestamp,
payload: Data(content.utf8),
signature: nil,
ttl: 3
)
}
private final class PublicCaptureDelegate: BitchatDelegate {
private let lock = NSLock()
private(set) var publicMessages: [BitchatMessage] = []
func didReceivePublicMessage(from peerID: PeerID, nickname: String, content: String, timestamp: Date, messageID: String?) {
let message = BitchatMessage(
id: messageID,
sender: nickname,
content: content,
timestamp: timestamp,
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: peerID,
mentions: nil
)
lock.lock()
publicMessages.append(message)
lock.unlock()
}
func didReceiveMessage(_ message: BitchatMessage) {}
func didConnectToPeer(_ peerID: PeerID) {}
func didDisconnectFromPeer(_ peerID: PeerID) {}
func didUpdatePeerList(_ peers: [PeerID]) {}
func didUpdateBluetoothState(_ state: CBManagerState) {}
func publicMessagesSnapshot() -> [BitchatMessage] {
lock.lock()
defer { lock.unlock() }
return publicMessages
}
}
+7 -7
View File
@@ -73,7 +73,7 @@ struct BLEServiceTests {
service.sendMessage("Hello, world!")
// Allow async processing
try await sleep(0.5)
try await sleep(1.0)
}
#expect(service.sentMessages.count == 1)
}
@@ -97,7 +97,7 @@ struct BLEServiceTests {
)
// Allow async processing
try await sleep(0.5)
try await sleep(1.0)
}
#expect(service.sentMessages.count == 1)
}
@@ -113,7 +113,7 @@ struct BLEServiceTests {
service.sendMessage("@alice @bob check this out", mentions: ["alice", "bob"])
// Allow async processing
try await sleep(0.5)
try await sleep(1.0)
}
}
@@ -146,7 +146,7 @@ struct BLEServiceTests {
service.simulateIncomingMessage(incomingMessage)
// Allow async processing
try await sleep(0.5)
try await sleep(1.0)
}
}
@@ -189,7 +189,7 @@ struct BLEServiceTests {
service.simulateIncomingPacket(packet)
// Allow async processing
try await sleep(0.5)
try await sleep(1.0)
}
}
@@ -231,7 +231,7 @@ struct BLEServiceTests {
service.sendMessage("Test delivery")
// Allow async processing
try await sleep(0.5)
try await sleep(1.0)
}
}
@@ -273,7 +273,7 @@ struct BLEServiceTests {
service.simulateIncomingPacket(packet)
// Allow async processing
try await sleep(0.5)
try await sleep(1.0)
}
}
}
@@ -0,0 +1,228 @@
//
// ChatViewModelDeliveryStatusTests.swift
// bitchatTests
//
// Tests for ChatViewModel delivery status state machine.
//
import Testing
import Foundation
@testable import bitchat
// MARK: - Test Helpers
@MainActor
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
let keychain = MockKeychain()
let keychainHelper = MockKeychainHelper()
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
let identityManager = MockIdentityManager(keychain)
let transport = MockTransport()
let viewModel = ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
transport: transport
)
return (viewModel, transport)
}
// MARK: - Delivery Status Tests
struct ChatViewModelDeliveryStatusTests {
// MARK: - Status Transition Tests
@Test @MainActor
func deliveryStatus_noDowngrade_readToDelivered() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0102030405060708")
let messageID = "test-msg-1"
// Setup: create a message with .read status
let message = BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Test message",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Peer",
senderPeerID: transport.myPeerID,
deliveryStatus: .read(by: "Peer", at: Date())
)
viewModel.privateChats[peerID] = [message]
// Action: try to downgrade to .delivered
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
// Assert: status should remain .read (no downgrade)
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
#expect({
if case .read = currentStatus { return true }
return false
}())
}
@Test @MainActor
func deliveryStatus_upgrade_sentToDelivered() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0102030405060708")
let messageID = "test-msg-2"
// Setup: create a message with .sent status
let message = BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Test message",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Peer",
senderPeerID: transport.myPeerID,
deliveryStatus: .sent
)
viewModel.privateChats[peerID] = [message]
// Action: upgrade to .delivered
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .delivered(to: "Peer", at: Date()))
// Assert: status should be .delivered
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
#expect({
if case .delivered = currentStatus { return true }
return false
}())
}
@Test @MainActor
func deliveryStatus_upgrade_deliveredToRead() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0102030405060708")
let messageID = "test-msg-3"
// Setup: create a message with .delivered status
let message = BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Test message",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Peer",
senderPeerID: transport.myPeerID,
deliveryStatus: .delivered(to: "Peer", at: Date().addingTimeInterval(-60))
)
viewModel.privateChats[peerID] = [message]
// Action: upgrade to .read
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .read(by: "Peer", at: Date()))
// Assert: status should be .read
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
#expect({
if case .read = currentStatus { return true }
return false
}())
}
// MARK: - Read Receipt Handling
@Test @MainActor
func didReceiveReadReceipt_updatesMessageStatus() async {
let (viewModel, transport) = makeTestableViewModel()
let peerID = PeerID(str: "0102030405060708")
let messageID = "test-msg-4"
// Setup: create a message with .sent status
let message = BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Test message",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Peer",
senderPeerID: transport.myPeerID,
deliveryStatus: .sent
)
viewModel.privateChats[peerID] = [message]
// Action: receive read receipt
let receipt = ReadReceipt(
originalMessageID: messageID,
readerID: peerID,
readerNickname: "Peer"
)
viewModel.didReceiveReadReceipt(receipt)
// Assert: status should be .read
let currentStatus = viewModel.privateChats[peerID]?.first?.deliveryStatus
#expect({
if case .read = currentStatus { return true }
return false
}())
}
// MARK: - Public Timeline Status Tests
@Test @MainActor
func deliveryStatus_publicTimeline_updatesCorrectly() async {
let (viewModel, _) = makeTestableViewModel()
let messageID = "public-msg-1"
// Setup: add a message to public timeline with .sending status
let message = BitchatMessage(
id: messageID,
sender: viewModel.nickname,
content: "Public message",
timestamp: Date(),
isRelay: false,
isPrivate: false,
deliveryStatus: .sending
)
viewModel.messages.append(message)
// Action: update to .sent
viewModel.didUpdateMessageDeliveryStatus(messageID, status: .sent)
// Assert
let updatedMessage = viewModel.messages.first(where: { $0.id == messageID })
#expect({
if case .sent = updatedMessage?.deliveryStatus { return true }
return false
}())
}
// MARK: - Status Rank Tests (for deduplication)
@Test @MainActor
func statusRank_orderingIsCorrect() async {
// This tests the implicit ordering used in refreshVisibleMessages
// failed < sending < sent < partiallyDelivered < delivered < read
let statuses: [DeliveryStatus] = [
.failed(reason: "test"),
.sending,
.sent,
.partiallyDelivered(reached: 1, total: 3),
.delivered(to: "B", at: Date()),
.read(by: "C", at: Date())
]
// Verify each status has a logical progression
// This is more of a documentation test to ensure the ranking logic is understood
for (index, status) in statuses.enumerated() {
switch status {
case .failed: #expect(index == 0)
case .sending: #expect(index == 1)
case .sent: #expect(index == 2)
case .partiallyDelivered: #expect(index == 3)
case .delivered: #expect(index == 4)
case .read: #expect(index == 5)
}
}
}
}
+138 -15
View File
@@ -64,6 +64,22 @@ struct ChatViewModelPrivateChatExtensionTests {
// MockTransport.sendPrivateMessage is what MessageRouter calls for connected peers
// Check MockTransport implementation... it might need update or verification
}
@Test @MainActor
func sendPrivateMessage_unreachable_setsFailedStatus() async {
let (viewModel, _) = makeTestableViewModel()
let validHex = "0102030405060708090a0b0c0d0e0f100102030405060708090a0b0c0d0e0f10"
let peerID = PeerID(str: validHex)
viewModel.sendPrivateMessage("Hello", to: peerID)
#expect(viewModel.privateChats[peerID]?.count == 1)
let status = viewModel.privateChats[peerID]?.last?.deliveryStatus
#expect({
if case .failed = status { return true }
return false
}())
}
@Test @MainActor
func handlePrivateMessage_storesMessage() async {
@@ -250,10 +266,17 @@ struct ChatViewModelNostrExtensionTests {
@Test @MainActor
func subscribeNostrEvent_addsToTimeline_ifMatchesGeohash() async {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let channel = ChannelID.location(GeohashChannel(level: .city, geohash: geohash))
LocationChannelManager.shared.select(channel)
defer { LocationChannelManager.shared.select(.mesh) }
_ = await TestHelpers.waitUntil({ LocationChannelManager.shared.selectedChannel == channel })
let (viewModel, _) = makeTestableViewModel()
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
_ = await TestHelpers.waitUntil({ viewModel.activeChannel == channel })
var event = NostrEvent(
pubkey: "pub1",
@@ -267,19 +290,119 @@ struct ChatViewModelNostrExtensionTests {
viewModel.handleNostrEvent(event)
// Allow async processing
let didAppend = await TestHelpers.waitUntil({
viewModel.publicMessagePipeline.flushIfNeeded()
return viewModel.messages.contains { $0.content == "Hello Geo" }
})
#expect(didAppend)
}
@Test @MainActor
func handleNostrEvent_ignoresRecentSelfEcho() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
var event = NostrEvent(
pubkey: identity.publicKeyHex,
createdAt: Date(),
kind: .ephemeralEvent,
tags: [["g", geohash]],
content: "Self echo"
)
event.id = "evt-self"
viewModel.handleNostrEvent(event)
try? await Task.sleep(nanoseconds: 100_000_000)
// Check timeline
// This depends on `handlePublicMessage` being called and updating `messages`
// Since `handlePublicMessage` delegates to `timelineStore` and updates `messages`...
// And we are in the correct channel...
// However, `handleNostrEvent` in the extension now calls `handlePublicMessage`.
// Let's verify if the message appears.
// Note: `handleNostrEvent` logic was refactored.
// The new logic in `ChatViewModel+Nostr.swift` calls `handlePublicMessage`.
// We need to ensure `deduplicationService` doesn't block it (new instance, so empty).
viewModel.publicMessagePipeline.flushIfNeeded()
#expect(!viewModel.messages.contains { $0.content == "Self echo" })
}
@Test @MainActor
func handleNostrEvent_skipsBlockedSender() async {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let blockedPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
viewModel.identityManager.setNostrBlocked(blockedPubkey, isBlocked: true)
var event = NostrEvent(
pubkey: blockedPubkey,
createdAt: Date(),
kind: .ephemeralEvent,
tags: [["g", geohash]],
content: "Blocked"
)
event.id = "evt-blocked"
viewModel.handleNostrEvent(event)
try? await Task.sleep(nanoseconds: 100_000_000)
viewModel.publicMessagePipeline.flushIfNeeded()
#expect(!viewModel.messages.contains { $0.content == "Blocked" })
}
@Test @MainActor
func switchLocationChannel_clearsNostrDedupCache() async {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
viewModel.deduplicationService.recordNostrEvent("evt-cache")
#expect(viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache"))
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
#expect(!viewModel.deduplicationService.hasProcessedNostrEvent("evt-cache"))
}
}
// MARK: - Geohash Queue Tests
struct ChatViewModelGeohashQueueTests {
@Test @MainActor
func addGeohashOnlySystemMessage_queuesUntilLocationChannel() async {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
viewModel.addGeohashOnlySystemMessage("Queued system")
#expect(!viewModel.messages.contains { $0.content == "Queued system" })
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
#expect(viewModel.messages.contains { $0.content == "Queued system" })
}
}
// MARK: - GeoDM Tests
struct ChatViewModelGeoDMTests {
@Test @MainActor
func handlePrivateMessage_geohash_dedupsAndTracksAck() async throws {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let senderPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
let messageID = "pm-1"
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: geohash)))
let identity = try viewModel.idBridge.deriveIdentity(forGeohash: geohash)
let convKey = PeerID(nostr_: senderPubkey)
let packet = PrivateMessagePacket(messageID: messageID, content: "Hello")
let payloadData = try #require(packet.encode(), "Failed to encode private message")
let payload = NoisePayload(type: .privateMessage, data: payloadData)
viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date())
viewModel.handlePrivateMessage(payload, senderPubkey: senderPubkey, convKey: convKey, id: identity, messageTimestamp: Date())
#expect(viewModel.privateChats[convKey]?.count == 1)
#expect(viewModel.sentGeoDeliveryAcks.contains(messageID))
}
}
@@ -0,0 +1,154 @@
//
// ChatViewModelRefactoringTests.swift
// bitchatTests
//
// Pinning tests to characterize ChatViewModel behavior before refactoring.
// These tests act as a safety net to ensure we don't break existing functionality.
//
import Testing
import Foundation
@testable import bitchat
struct ChatViewModelRefactoringTests {
// Helper to setup the environment
@MainActor
private func makePinnedViewModel() -> (viewModel: ChatViewModel, transport: MockTransport, identity: MockIdentityManager) {
let keychain = MockKeychain()
let keychainHelper = MockKeychainHelper()
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
let identityManager = MockIdentityManager(keychain)
let transport = MockTransport()
let viewModel = ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
transport: transport
)
return (viewModel, transport, identityManager)
}
// MARK: - Command Processor Integration "Pinning"
@Test @MainActor
func command_msg_routesToTransport() async throws {
let (viewModel, transport, _) = makePinnedViewModel()
// Setup: Use simulateConnect so ChatViewModel and UnifiedPeerService are notified
let peerID = PeerID(str: "0000000000000001")
transport.simulateConnect(peerID, nickname: "alice")
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("alice") != nil },
timeout: TestConstants.shortTimeout)
#expect(didResolve)
// Action: User types /msg command
viewModel.sendMessage("/msg @alice Hello Private World")
let didSend = await TestHelpers.waitUntil({ transport.sentPrivateMessages.count == 1 },
timeout: TestConstants.shortTimeout)
#expect(didSend)
// Assert:
// 1. Should NOT go to public transport
#expect(transport.sentMessages.isEmpty, "Command should not be sent as public message")
// 2. Should go to private transport logic
#expect(transport.sentPrivateMessages.count == 1)
#expect(transport.sentPrivateMessages.first?.content == "Hello Private World")
#expect(transport.sentPrivateMessages.first?.peerID == peerID)
}
@Test @MainActor
func command_block_updatesIdentity() async throws {
let (viewModel, transport, identity) = makePinnedViewModel()
// Setup: Use simulateConnect
let peerID = PeerID(str: "0000000000000002")
// Mock the fingerprint so the block command finds it
transport.peerFingerprints[peerID] = "fingerprint_123"
transport.simulateConnect(peerID, nickname: "troll")
let didResolve = await TestHelpers.waitUntil({ viewModel.getPeerIDForNickname("troll") != nil },
timeout: TestConstants.shortTimeout)
#expect(didResolve)
// Action
viewModel.sendMessage("/block @troll")
// Assert
// Verify identity manager was called to block "fingerprint_123"
let didBlock = await TestHelpers.waitUntil({ identity.isBlocked(fingerprint: "fingerprint_123") },
timeout: TestConstants.shortTimeout)
#expect(didBlock)
}
// MARK: - Message Routing Logic
@Test @MainActor
func routing_incomingPrivateMessage_addsToPrivateChats() async {
let (viewModel, _, _) = makePinnedViewModel()
let senderID = PeerID(str: "sender_1")
// Setup
let message = BitchatMessage(
id: "msg_1",
sender: "bob",
content: "Secret",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: true,
recipientNickname: "me",
senderPeerID: senderID,
mentions: nil
)
// Action: Simulate incoming private message
viewModel.didReceiveMessage(message)
// Wait for async processing with proper timeout
let found = await TestHelpers.waitUntil(
{ viewModel.privateChats[senderID]?.first?.content == "Secret" },
timeout: TestConstants.defaultTimeout
)
// Assert
#expect(found)
}
@Test @MainActor
func routing_incomingPublicMessage_addsToPublicTimeline() async {
let (viewModel, _, _) = makePinnedViewModel()
let senderID = PeerID(str: "sender_2")
// Setup
let message = BitchatMessage(
id: "msg_2",
sender: "charlie",
content: "Public Hi",
timestamp: Date(),
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: senderID,
mentions: nil
)
// Action
viewModel.didReceiveMessage(message)
// Wait for async processing with proper timeout
let found = await TestHelpers.waitUntil(
{ viewModel.messages.contains(where: { $0.content == "Public Hi" }) },
timeout: TestConstants.defaultTimeout
)
// Assert
#expect(found)
}
}
+186
View File
@@ -114,6 +114,44 @@ struct ChatViewModelSendingTests {
}
}
// MARK: - Command Handling Tests
struct ChatViewModelCommandTests {
@Test @MainActor
func sendMessage_commandsNotSentToTransport() async {
let (viewModel, transport) = makeTestableViewModel()
let commands = ["/nick bob", "/who", "/help", "/clear"]
for command in commands {
transport.resetRecordings()
viewModel.sendMessage(command)
try? await Task.sleep(nanoseconds: 100_000_000)
#expect(transport.sentMessages.isEmpty)
#expect(transport.sentPrivateMessages.isEmpty)
}
}
}
// MARK: - Timeline Cap Tests
struct ChatViewModelTimelineCapTests {
@Test @MainActor
func sendMessage_trimsTimelineToCap() async {
let (viewModel, _) = makeTestableViewModel()
let total = TransportConfig.meshTimelineCap + 5
for i in 0..<total {
viewModel.sendMessage("cap-msg-\(i)")
}
#expect(viewModel.messages.count == TransportConfig.meshTimelineCap)
#expect(viewModel.messages.last?.content == "cap-msg-\(total - 1)")
}
}
// MARK: - Message Receiving Tests
struct ChatViewModelReceivingTests {
@@ -164,6 +202,40 @@ struct ChatViewModelReceivingTests {
}
}
// MARK: - Rate Limiting Tests
struct ChatViewModelRateLimitingTests {
@Test @MainActor
func handlePublicMessage_rateLimitsBurstBySender() async {
let (viewModel, _) = makeTestableViewModel()
let senderID = PeerID(str: "1122334455667788")
let now = Date()
for i in 0..<6 {
let message = BitchatMessage(
id: "rate-\(i)",
sender: "Spammer",
content: "rate-msg-\(i)",
timestamp: now,
isRelay: false,
originalSender: nil,
isPrivate: false,
recipientNickname: nil,
senderPeerID: senderID,
mentions: nil
)
viewModel.handlePublicMessage(message)
}
viewModel.publicMessagePipeline.flushIfNeeded()
let burstMessages = viewModel.messages.filter { $0.content.hasPrefix("rate-msg-") }
#expect(burstMessages.count == 5)
#expect(!burstMessages.contains { $0.content == "rate-msg-5" })
}
}
// MARK: - Peer Connection Tests
struct ChatViewModelPeerTests {
@@ -258,6 +330,94 @@ struct ChatViewModelPrivateChatTests {
}
}
// MARK: - Private Chat Selection Tests
struct ChatViewModelPrivateChatSelectionTests {
@Test @MainActor
func openMostRelevantPrivateChat_prefersUnreadMostRecent() async {
let (viewModel, _) = makeTestableViewModel()
let peerA = PeerID(str: "PEER_A")
let peerB = PeerID(str: "PEER_B")
let older = Date().addingTimeInterval(-120)
let newer = Date().addingTimeInterval(-30)
viewModel.privateChats = [
peerA: [
BitchatMessage(
id: "a-1",
sender: "A",
content: "Old",
timestamp: older,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerA
)
],
peerB: [
BitchatMessage(
id: "b-1",
sender: "B",
content: "New",
timestamp: newer,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerB
)
]
]
viewModel.unreadPrivateMessages = [peerA, peerB]
viewModel.openMostRelevantPrivateChat()
#expect(viewModel.selectedPrivateChatPeer == peerB)
}
@Test @MainActor
func openMostRelevantPrivateChat_fallsBackToMostRecentChat() async {
let (viewModel, _) = makeTestableViewModel()
let peerA = PeerID(str: "PEER_A")
let peerB = PeerID(str: "PEER_B")
let older = Date().addingTimeInterval(-200)
let newer = Date().addingTimeInterval(-20)
viewModel.privateChats = [
peerA: [
BitchatMessage(
id: "a-1",
sender: "A",
content: "Old",
timestamp: older,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerA
)
],
peerB: [
BitchatMessage(
id: "b-1",
sender: "B",
content: "New",
timestamp: newer,
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerB
)
]
]
viewModel.openMostRelevantPrivateChat()
#expect(viewModel.selectedPrivateChatPeer == peerB)
}
}
// MARK: - Bluetooth State Tests
struct ChatViewModelBluetoothTests {
@@ -309,11 +469,37 @@ struct ChatViewModelPanicTests {
// Set up some state
transport.connectedPeers.insert(PeerID(str: "PEER1"))
viewModel.messages = [
BitchatMessage(
id: "panic-1",
sender: "Tester",
content: "Before",
timestamp: Date(),
isRelay: false
)
]
viewModel.privateChats[PeerID(str: "PEER1")] = [
BitchatMessage(
id: "pm-1",
sender: "Peer",
content: "Secret",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: PeerID(str: "PEER1")
)
]
viewModel.unreadPrivateMessages.insert(PeerID(str: "PEER1"))
viewModel.panicClearAllData()
// After panic, emergency disconnect should be called
#expect(transport.emergencyDisconnectCallCount == 1)
#expect(viewModel.messages.isEmpty)
#expect(viewModel.privateChats.isEmpty)
#expect(viewModel.unreadPrivateMessages.isEmpty)
#expect(viewModel.selectedPrivateChatPeer == nil)
}
}
+178
View File
@@ -0,0 +1,178 @@
//
// ChatViewModelTorTests.swift
// bitchatTests
//
// Tests for ChatViewModel+Tor.swift Tor lifecycle notification handlers.
//
import Testing
import Foundation
@testable import bitchat
// MARK: - Test Helpers
@MainActor
private func makeTestableViewModel() -> (viewModel: ChatViewModel, transport: MockTransport) {
let keychain = MockKeychain()
let keychainHelper = MockKeychainHelper()
let idBridge = NostrIdentityBridge(keychain: keychainHelper)
let identityManager = MockIdentityManager(keychain)
let transport = MockTransport()
let viewModel = ChatViewModel(
keychain: keychain,
idBridge: idBridge,
identityManager: identityManager,
transport: transport
)
return (viewModel, transport)
}
// MARK: - Tor Notification Handler Tests
struct ChatViewModelTorTests {
// MARK: - handleTorWillStart Tests
@Test @MainActor
func handleTorWillStart_whenEnforced_setsAnnouncedFlag() async {
let (viewModel, _) = makeTestableViewModel()
// Precondition: flag should start false
#expect(!viewModel.torStatusAnnounced)
// Action: simulate Tor starting notification
viewModel.handleTorWillStart()
// Wait for Task to complete
try? await Task.sleep(nanoseconds: 100_000_000)
// Assert: flag should be set (torEnforced is true in tests)
#expect(viewModel.torStatusAnnounced)
}
@Test @MainActor
func handleTorWillStart_whenAlreadyAnnounced_doesNotDuplicate() async {
let (viewModel, _) = makeTestableViewModel()
// Setup: pre-set the flag
viewModel.torStatusAnnounced = true
// Switch to a geohash channel so messages would be visible
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq")))
try? await Task.sleep(nanoseconds: 100_000_000)
let initialMessageCount = viewModel.messages.count
// Action: call handler again
viewModel.handleTorWillStart()
try? await Task.sleep(nanoseconds: 100_000_000)
// Assert: no new message added (flag was already true)
#expect(viewModel.messages.count == initialMessageCount)
}
// MARK: - handleTorWillRestart Tests
@Test @MainActor
func handleTorWillRestart_setsPendingFlag() async {
let (viewModel, _) = makeTestableViewModel()
// Precondition
#expect(!viewModel.torRestartPending)
// Action
viewModel.handleTorWillRestart()
try? await Task.sleep(nanoseconds: 100_000_000)
// Assert
#expect(viewModel.torRestartPending)
}
@Test @MainActor
func handleTorWillRestart_setsFlag_regardlessOfChannel() async {
let (viewModel, _) = makeTestableViewModel()
// Action: call handler (works regardless of channel)
viewModel.handleTorWillRestart()
try? await Task.sleep(nanoseconds: 100_000_000)
// Assert: flag should be set
#expect(viewModel.torRestartPending)
}
// MARK: - handleTorDidBecomeReady Tests
@Test @MainActor
func handleTorDidBecomeReady_afterRestart_clearsPendingFlag() async {
let (viewModel, _) = makeTestableViewModel()
// Setup: simulate restart pending state
viewModel.torRestartPending = true
// Action
viewModel.handleTorDidBecomeReady()
try? await Task.sleep(nanoseconds: 100_000_000)
// Assert: should clear pending flag
#expect(!viewModel.torRestartPending)
}
@Test @MainActor
func handleTorDidBecomeReady_initialStart_setsAnnouncedFlag() async {
let (viewModel, _) = makeTestableViewModel()
// Setup: not restarting, but initial ready not announced yet
viewModel.torRestartPending = false
viewModel.torInitialReadyAnnounced = false
// Action
viewModel.handleTorDidBecomeReady()
try? await Task.sleep(nanoseconds: 100_000_000)
// Assert: should set flag (torEnforced is true in tests)
#expect(viewModel.torInitialReadyAnnounced)
}
@Test @MainActor
func handleTorDidBecomeReady_alreadyAnnounced_noDuplicate() async {
let (viewModel, _) = makeTestableViewModel()
// Setup: already announced initial ready
viewModel.torRestartPending = false
viewModel.torInitialReadyAnnounced = true
viewModel.switchLocationChannel(to: .location(GeohashChannel(level: .city, geohash: "u4pruydq")))
try? await Task.sleep(nanoseconds: 100_000_000)
let initialMessageCount = viewModel.messages.count
// Action
viewModel.handleTorDidBecomeReady()
try? await Task.sleep(nanoseconds: 100_000_000)
// Assert: no new message
#expect(viewModel.messages.count == initialMessageCount)
}
// MARK: - handleTorPreferenceChanged Tests
@Test @MainActor
func handleTorPreferenceChanged_resetsAllFlags() async {
let (viewModel, _) = makeTestableViewModel()
// Setup: set all flags
viewModel.torStatusAnnounced = true
viewModel.torInitialReadyAnnounced = true
viewModel.torRestartPending = true
// Action
viewModel.handleTorPreferenceChanged(Notification(name: .init("test")))
try? await Task.sleep(nanoseconds: 100_000_000)
// Assert: all flags reset
#expect(!viewModel.torStatusAnnounced)
#expect(!viewModel.torInitialReadyAnnounced)
#expect(!viewModel.torRestartPending)
}
}
+3 -3
View File
@@ -6,7 +6,7 @@ struct CommandProcessorTests {
@MainActor
@Test func slapNotFoundGrammar() {
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/slap @system")
switch result {
case .error(let message):
@@ -18,7 +18,7 @@ struct CommandProcessorTests {
@MainActor
@Test func hugNotFoundGrammar() {
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/hug @system")
switch result {
case .error(let message):
@@ -30,7 +30,7 @@ struct CommandProcessorTests {
@MainActor
@Test func slapUsageMessage() {
let processor = CommandProcessor(chatViewModel: nil, meshService: nil, identityManager: identityManager)
let processor = CommandProcessor(contextProvider: nil, meshService: nil, identityManager: identityManager)
let result = processor.process("/slap")
switch result {
case .error(let message):
@@ -134,7 +134,7 @@ struct FragmentationTests {
}
}
try await sleep(1.0)
try await capture.waitForReceivedMessages(count: 1, timeout: .seconds(2))
let message = try #require(capture.receivedMessages.first, "Expected file transfer message")
#expect(message.content.hasPrefix("[file]"))
+17 -7
View File
@@ -7,12 +7,14 @@ struct GossipSyncManagerTests {
private let myPeerID = PeerID(str: "0102030405060708")
@Test func concurrentPacketIntakeAndSyncRequest() async throws {
let manager = GossipSyncManager(myPeerID: myPeerID)
let requestSyncManager = RequestSyncManager()
let manager = GossipSyncManager(myPeerID: myPeerID, requestSyncManager: requestSyncManager)
let delegate = RecordingDelegate()
manager.delegate = delegate
try await confirmation("sync request sent") { sent in
delegate.onSend = {
delegate.onSend = nil
sent()
}
@@ -34,7 +36,7 @@ struct GossipSyncManagerTests {
}
manager.scheduleInitialSyncToPeer(PeerID(str: "FFFFFFFFFFFFFFFF"), delaySeconds: 0.0)
try await sleep(0.002)
try await TestHelpers.waitFor({ delegate.lastPacket != nil }, timeout: TestConstants.shortTimeout)
}
let lastPacket = try #require(delegate.lastPacket, "Expected sync packet to be sent")
@@ -47,7 +49,8 @@ struct GossipSyncManagerTests {
config.stalePeerCleanupIntervalSeconds = 0
config.stalePeerTimeoutSeconds = 5
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
let requestSyncManager = RequestSyncManager()
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
let peerHex = "0011223344556677"
let senderData = try #require(Data(hexString: peerHex))
let initialTimestampMs = UInt64(Date().timeIntervalSince1970 * 1000)
@@ -92,7 +95,8 @@ struct GossipSyncManagerTests {
config.stalePeerTimeoutSeconds = 5
config.maxMessageAgeSeconds = 100
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
let requestSyncManager = RequestSyncManager()
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
let peerHex = "8899aabbccddeeff"
let senderData = try #require(Data(hexString: peerHex))
let staleTimestampMs = UInt64(Date().addingTimeInterval(-(config.stalePeerTimeoutSeconds + 1)).timeIntervalSince1970 * 1000)
@@ -136,7 +140,8 @@ struct GossipSyncManagerTests {
config.fileTransferSyncIntervalSeconds = 1
config.maintenanceIntervalSeconds = 0
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
let requestSyncManager = RequestSyncManager()
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
let delegate = RecordingDelegate()
manager.delegate = delegate
@@ -206,7 +211,8 @@ struct GossipSyncManagerTests {
config.fragmentSyncIntervalSeconds = 0
config.fileTransferSyncIntervalSeconds = 0
let manager = GossipSyncManager(myPeerID: myPeerID, config: config)
let requestSyncManager = RequestSyncManager()
let manager = GossipSyncManager(myPeerID: myPeerID, config: config, requestSyncManager: requestSyncManager)
let delegate = RecordingDelegate()
manager.delegate = delegate
@@ -240,7 +246,7 @@ struct GossipSyncManagerTests {
let request = RequestSyncPacket(p: 4, m: 1, data: Data(), types: .fragment)
manager.handleRequestSync(from: peer, request: request)
try await sleep(0.01)
try await TestHelpers.waitFor({ delegate.packets.count == 1 }, timeout: TestConstants.shortTimeout)
let sentPackets = delegate.packets
#expect(sentPackets.count == 1)
#expect(sentPackets[0].type == MessageType.fragment.rawValue)
@@ -268,4 +274,8 @@ private final class RecordingDelegate: GossipSyncManager.Delegate {
func signPacketForBroadcast(_ packet: BitchatPacket) -> BitchatPacket {
packet
}
func getConnectedPeers() -> [PeerID] {
return []
}
}
-530
View File
@@ -1,530 +0,0 @@
//import Foundation
//import XCTest
//@testable import bitchat
//
//private let localizationTestsDirectoryURL = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
//private let testsRootURL = localizationTestsDirectoryURL.deletingLastPathComponent()
//private let repoRootURL = testsRootURL.deletingLastPathComponent()
//
//final class LocalizationCatalogTests: XCTestCase {
// // Ensures every app locale includes exactly the same keys as Base.
// func testAppCatalogLocaleParity() throws {
// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
// assertLocaleParity(context: context, catalogName: "App")
// }
//
// // Verifies format placeholders stay consistent across app locales.
// func testAppCatalogPlaceholderConsistency() throws {
// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
// assertPlaceholderConsistency(context: context, catalogName: "App")
// }
//
// // Guards a core set of app strings from going empty per locale.
// func testAppPrimaryKeysNonEmpty() throws {
// let context = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
// let primaryKeys = try loadPrimaryKeys().app
// assertPrimaryKeysPresent(context: context, keys: primaryKeys, catalogName: "App")
// }
//
// // Ensures every share extension locale matches Base key coverage.
// func testShareExtensionCatalogLocaleParity() throws {
// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
// assertLocaleParity(context: context, catalogName: "ShareExtension")
// }
//
// // Verifies share extension placeholders align across locales.
// func testShareExtensionCatalogPlaceholderConsistency() throws {
// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
// assertPlaceholderConsistency(context: context, catalogName: "ShareExtension")
// }
//
// // Confirms critical share extension strings remain non-empty per locale.
// func testShareExtensionPrimaryKeysNonEmpty() throws {
// let context = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
// let primaryKeys = try loadPrimaryKeys().shareExtension
// assertPrimaryKeysPresent(context: context, keys: primaryKeys, catalogName: "ShareExtension")
// }
//
// // Validates that configured locales contain expected string values.
// func testLocalizationExpectedValues() throws {
// let appContext = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
// let shareContext = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
// let config = try loadPrimaryKeys()
//
// guard let testLocales = config.testLocales else {
// // If no testLocales specified, skip this test
// return
// }
//
// guard let expectedValues = config.expectedValues else {
// XCTFail("No expectedValues configured in PrimaryLocalizationKeys.json")
// return
// }
//
// // Loop through each locale to test
// for locale in testLocales {
// guard let localeExpectedValues = expectedValues[locale] else {
// XCTFail("No expected values configured for locale '\(locale)' in PrimaryLocalizationKeys.json")
// continue
// }
//
// // Test each expected key/value pair for this locale
// for (key, expectedValue) in localeExpectedValues {
// if config.app.contains(key) {
// assertLocaleStringValue(context: appContext, locale: locale, key: key, expectedValue: expectedValue, catalogName: "App")
// } else if config.shareExtension.contains(key) {
// assertLocaleStringValue(context: shareContext, locale: locale, key: key, expectedValue: expectedValue, catalogName: "ShareExtension")
// }
// }
// }
// }
//
// // Ensures configured test locales are present and complete.
// func testConfiguredLocalesCompleteness() throws {
// let appContext = try loadContext(relativePath: "bitchat/Localizable.xcstrings")
// let shareContext = try loadContext(relativePath: "bitchatShareExtension/Localization/Localizable.xcstrings")
// let config = try loadPrimaryKeys()
//
// guard let testLocales = config.testLocales else {
// // If no testLocales specified, skip this test
// return
// }
//
// let baseLocale = appContext.baseLocale
// let baseAppKeys = appContext.keysByLocale[baseLocale] ?? Set()
// let baseShareKeys = shareContext.keysByLocale[baseLocale] ?? Set()
//
// for locale in testLocales {
// // Skip base locale comparison with itself
// if locale == baseLocale { continue }
//
// // Verify locale is present in both catalogs
// XCTAssertTrue(appContext.locales.contains(locale), "Locale '\(locale)' missing from app catalog")
// XCTAssertTrue(shareContext.locales.contains(locale), "Locale '\(locale)' missing from share extension catalog")
//
// // Verify locale has same number of keys as base locale
// let appLocaleKeys = appContext.keysByLocale[locale] ?? Set()
// XCTAssertEqual(appLocaleKeys.count, baseAppKeys.count, "Locale '\(locale)' app catalog missing keys compared to \(baseLocale)")
//
// let shareLocaleKeys = shareContext.keysByLocale[locale] ?? Set()
// XCTAssertEqual(shareLocaleKeys.count, baseShareKeys.count, "Locale '\(locale)' share extension catalog missing keys compared to \(baseLocale)")
// }
// }
//
// // MARK: - Assertions
//
// private func assertLocaleParity(context: CatalogContext, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
// let baseLocale = context.baseLocale
// guard let baseKeys = context.keysByLocale[baseLocale] else {
// return XCTFail("Missing base locale \(baseLocale) in \(catalogName) catalog", file: file, line: line)
// }
//
// for (locale, keys) in context.keysByLocale.sorted(by: { $0.key < $1.key }) {
// XCTAssertEqual(keys, baseKeys, "Locale \(locale) has key mismatch in \(catalogName) catalog", file: file, line: line)
// }
// }
//
// private func assertPlaceholderConsistency(context: CatalogContext, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
// let baseLocale = context.baseLocale
// guard let baseSignatures = context.placeholderSignature[baseLocale] else {
// return XCTFail("Missing base placeholder signature for \(catalogName)", file: file, line: line)
// }
//
// for (locale, localeSignatures) in context.placeholderSignature.sorted(by: { $0.key < $1.key }) {
// guard locale != baseLocale else { continue }
// for key in baseSignatures.keys.sorted() {
// guard let baseMap = baseSignatures[key] else {
// continue
// }
// guard let localeMap = localeSignatures[key] else {
// return XCTFail("Key \(key) missing for locale \(locale) in \(catalogName) catalog", file: file, line: line)
// }
// for path in baseMap.keys.sorted() {
// let expected = normalizedPlaceholders(baseMap[path, default: []])
// let actual = normalizedPlaceholders(localeMap[path, default: []])
// XCTAssertEqual(actual, expected, "Placeholder mismatch for key \(key) at \(path) in locale \(locale) (\(catalogName))", file: file, line: line)
// }
// for (localePath, localeTokens) in localeMap {
// guard baseMap[localePath] == nil else { continue }
// guard let fallback = fallbackPath(for: localePath, baseMap: baseMap) else {
// XCTFail("Unexpected variation \(localePath) for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
// continue
// }
// let expected = normalizedPlaceholders(baseMap[fallback, default: []])
// let actual = normalizedPlaceholders(localeTokens)
// XCTAssertEqual(actual, expected, "Placeholder mismatch for key \(key) at \(localePath) (fallback \(fallback)) in locale \(locale) (\(catalogName))", file: file, line: line)
// }
// }
// }
// }
//
// private func assertPrimaryKeysPresent(context: CatalogContext, keys: [String], catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
// for key in keys {
// guard let entry = context.catalog.strings[key] else {
// XCTFail("Missing primary key \(key) in \(catalogName) catalog", file: file, line: line)
// continue
// }
// for locale in context.locales.sorted() {
// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
// XCTFail("Missing localization for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
// continue
// }
// let segments = gatherSegments(from: unit)
// XCTAssertFalse(segments.isEmpty, "No content for key \(key) in locale \(locale) (\(catalogName))", file: file, line: line)
// for segment in segments {
// let trimmed = segment.value.trimmingCharacters(in: .whitespacesAndNewlines)
// XCTAssertFalse(trimmed.isEmpty, "Empty translation for key \(key) at \(segment.path) in locale \(locale) (\(catalogName))", file: file, line: line)
// }
// }
// }
// }
//
// private func assertLocaleStringValue(context: CatalogContext, locale: String, key: String, expectedValue: String, catalogName: String, file: StaticString = #filePath, line: UInt = #line) {
// guard let entry = context.catalog.strings[key] else {
// XCTFail("Missing key \(key) in \(catalogName) catalog", file: file, line: line)
// return
// }
//
// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
// XCTFail("Missing \(locale) localization for key \(key) in \(catalogName) catalog", file: file, line: line)
// return
// }
//
// // For simple strings (non-pluralized)
// if let actualValue = unit.value {
// XCTAssertEqual(actualValue, expectedValue, "\(locale) translation mismatch for key \(key) in \(catalogName) catalog. Expected: '\(expectedValue)', Actual: '\(actualValue)'", file: file, line: line)
// } else {
// XCTFail("Key \(key) has no value in \(locale) localization for \(catalogName) catalog", file: file, line: line)
// }
// }
//
// // MARK: - Loading
//
// private func loadContext(relativePath: String) throws -> CatalogContext {
// let catalog = try loadCatalog(relativePath: relativePath)
// let locales = catalog.locales
// let baseLocale = catalog.sourceLanguage
// var keysByLocale: [String: Set<String>] = [:]
// var placeholderSignature: [String: [String: [String: [String]]]] = [:]
//
// for locale in locales {
// var localeKeys: Set<String> = []
// var localePlaceholders: [String: [String: [String]]] = [:]
// for (key, entry) in catalog.strings {
// guard let localization = entry.localizations[locale], let unit = localization.stringUnit else {
// continue
// }
// localeKeys.insert(key)
// let segments = gatherSegments(from: unit)
// var pathMap: [String: [String]] = [:]
// for segment in segments {
// pathMap[segment.path] = placeholders(in: segment.value)
// }
// localePlaceholders[key] = pathMap
// }
// keysByLocale[locale] = localeKeys
// placeholderSignature[locale] = localePlaceholders
// }
//
// return CatalogContext(catalog: catalog, locales: locales, baseLocale: baseLocale, keysByLocale: keysByLocale, placeholderSignature: placeholderSignature)
// }
//
// private func loadCatalog(relativePath: String) throws -> StringCatalog {
// let url = repoRootURL.appendingPathComponent(relativePath)
// let data = try Data(contentsOf: url)
// return try JSONDecoder().decode(StringCatalog.self, from: data)
// }
//
// private func loadPrimaryKeys() throws -> PrimaryKeyConfig {
// let url = localizationTestsDirectoryURL.appendingPathComponent("PrimaryLocalizationKeys.json")
// let data = try Data(contentsOf: url)
// return try JSONDecoder().decode(PrimaryKeyConfig.self, from: data)
// }
//
// // MARK: - Regression Tests
//
// /// Helper method to access the app bundle for localization testing.
// /// This ensures tests use the actual app's Localizable.xcstrings instead of
// /// the test bundle, preventing false positives where localization keys are
// /// returned instead of translated strings.
// private func appBundle() -> Bundle {
// Bundle(for: ChatViewModel.self)
// }
//
// /// Tests to prevent regression of the pluralization format string issue
// /// These tests ensure that String(localized:) properly handles
// /// pluralized format strings with %#@variable@ syntax
// func testPluralizedFormatStringsDoNotCrash() {
// // These are the exact calls that were causing runtime errors
//
// // Test 1: content.accessibility.people_count with Int argument
// XCTAssertNoThrow({
// let result = String(
// localized: "content.accessibility.people_count",
// bundle: appBundle(),
// comment: "Accessibility label announcing number of people in header"
// )
// XCTAssertFalse(result.isEmpty, "Should return formatted string")
// // Verify we're getting actual localized content, not just the key
// XCTAssertNotEqual(result, "content.accessibility.people_count", "Should return localized string, not key")
// }(), "People count localization should not throw")
//
// // Test 2: location_notes.header with String and Int arguments
// XCTAssertNoThrow({
// let result = String(
// localized: "location_notes.header",
// bundle: appBundle(),
// comment: "Header displaying the geohash and localized note count"
// )
// XCTAssertFalse(result.isEmpty, "Should return formatted string")
// // Verify we're getting actual localized content, not just the key
// XCTAssertNotEqual(result, "location_notes.header", "Should return localized string, not key")
// }(), "Location notes header localization should not throw")
// }
//
// func testFormatStringArgumentMatching() {
// // Verify that the format strings properly handle their expected arguments
//
// // People count expects: %#@people@ format with Int
// let peopleResult = String(
// localized: "content.accessibility.people_count",
// bundle: appBundle(),
// comment: "Test"
// )
//
// // Should not show format specifiers in the base string
// XCTAssertFalse(peopleResult.isEmpty, "Should return non-empty string")
//
// // Location notes expects: #%@ %#@note_count@ format with String, Int
// let notesResult = String(
// localized: "location_notes.header",
// bundle: appBundle(),
// comment: "Test"
// )
//
// XCTAssertFalse(notesResult.isEmpty, "Should return non-empty string")
// }
//
// func testPluralizedStringsWithArguments() {
// // Test the pluralized strings with actual format arguments
//
// // Test people count with different values
// let testCases = [0, 1, 2, 5]
//
// for count in testCases {
// let result = String(
// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"),
// locale: .current,
// count
// )
//
// // Just verify it doesn't crash and returns a reasonable string
// XCTAssertFalse(result.isEmpty,
// "People count should return non-empty string for count \(count)")
// XCTAssertTrue(result.contains("\(count)"),
// "Result should contain the count number for count \(count)")
// }
// }
//
// func testLocationNotesHeaderWithArguments() {
// let geohash = "abc123"
// let testCases = [0, 1, 2, 10]
//
// for count in testCases {
// let result = String(
// format: String(localized: "location_notes.header", bundle: appBundle(), comment: "Test"),
// locale: .current,
// geohash, count
// )
//
// // Verify basic structure is maintained
// XCTAssertTrue(result.contains(geohash),
// "Result should contain geohash for count \(count)")
// XCTAssertTrue(result.contains("\(count)"),
// "Result should contain count for count \(count)")
// }
// }
//
// func testLocationChannelsRowTitleWithArguments() {
// let label = "test-channel"
// let testCases = [0, 1, 2, 5]
//
// for count in testCases {
// let result = String(
// format: String(localized: "location_channels.row_title", bundle: appBundle(), comment: "Test"),
// locale: .current,
// label, count
// )
//
// // Verify basic structure is maintained
// XCTAssertTrue(result.contains(label),
// "Result should contain label for count \(count)")
// XCTAssertTrue(result.contains("\(count)"),
// "Result should contain count for count \(count)")
// }
// }
//
// func testNoArgumentsLocalization() {
// // Test that strings without arguments still work
// let result = String(
// localized: "common.ok",
// bundle: appBundle(),
// comment: "OK button text"
// )
//
// XCTAssertFalse(result.isEmpty, "Simple localization should work")
// }
//
// func testLocalizationEdgeCases() {
// // Test edge cases that might cause issues
//
// // Large numbers
// let largeNumberResult = String(
// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"),
// locale: .current,
// 1000000
// )
// XCTAssertTrue(largeNumberResult.contains("1000000"), "Should handle large numbers")
//
// // Zero
// let zeroResult = String(
// format: String(localized: "content.accessibility.people_count", bundle: appBundle(), comment: "Test"),
// locale: .current,
// 0
// )
// XCTAssertTrue(zeroResult.contains("0"), "Should handle zero")
//
// // Empty geohash (edge case)
// let emptyGeohashResult = String(
// format: String(localized: "location_notes.header", bundle: appBundle(), comment: "Test"),
// locale: .current,
// "", 1
// )
// XCTAssertFalse(emptyGeohashResult.isEmpty, "Should handle empty geohash")
// }
//}
//
//// MARK: - Helpers
//
//private struct CatalogContext {
// let catalog: StringCatalog
// let locales: [String]
// let baseLocale: String
// let keysByLocale: [String: Set<String>]
// let placeholderSignature: [String: [String: [String: [String]]]]
//}
//
//private struct StringCatalog: Decodable {
// let sourceLanguage: String
// let strings: [String: CatalogEntry]
//
// var locales: [String] {
// var localeSet: Set<String> = []
// for entry in strings.values {
// localeSet.formUnion(entry.localizations.keys)
// }
// return localeSet.sorted()
// }
//}
//
//private struct CatalogEntry: Decodable {
// let localizations: [String: CatalogLocalization]
//}
//
//private struct CatalogLocalization: Decodable {
// let stringUnit: CatalogStringUnit?
//}
//
//private struct CatalogStringUnit: Decodable {
// let state: String
// let value: String?
// let variations: CatalogVariations?
// let comment: String?
//}
//
//private struct CatalogVariations: Decodable {
// let plural: [String: [String: CatalogVariationValue]]?
//}
//
//private struct CatalogVariationValue: Decodable {
// let stringUnit: CatalogStringUnit?
//}
//
//private struct Segment {
// let components: [String]
// let value: String
//
// var path: String {
// components.isEmpty ? "base" : components.joined(separator: ".")
// }
//}
//
//private func gatherSegments(from unit: CatalogStringUnit, prefix: [String] = []) -> [Segment] {
// var segments: [Segment] = []
// if let value = unit.value {
// segments.append(Segment(components: prefix, value: value))
// } else if prefix.isEmpty {
// segments.append(Segment(components: [], value: ""))
// }
// if let plural = unit.variations?.plural {
// for (variable, categories) in plural.sorted(by: { $0.key < $1.key }) {
// for (category, variation) in categories.sorted(by: { $0.key < $1.key }) {
// if let nested = variation.stringUnit {
// var nextPrefix = prefix
// nextPrefix.append("plural")
// nextPrefix.append(variable)
// nextPrefix.append(category)
// segments.append(contentsOf: gatherSegments(from: nested, prefix: nextPrefix))
// }
// }
// }
// }
// return segments
//}
//
//private func normalizedPlaceholders(_ tokens: [String]) -> [String] {
// tokens.sorted()
//}
//
//private func fallbackPath(for localePath: String, baseMap: [String: [String]]) -> String? {
// let parts = localePath.split(separator: ".")
// guard parts.count == 3, parts.first == "plural" else {
// return nil
// }
// let variable = parts[1]
// let otherKey = "plural.\(variable).other"
// if baseMap[otherKey] != nil {
// return otherKey
// }
// let oneKey = "plural.\(variable).one"
// if baseMap[oneKey] != nil {
// return oneKey
// }
// return nil
//}
//
//private let placeholderRegex: NSRegularExpression = {
// let pattern = "%(?:\\d+\\$)?#@[A-Za-z0-9_]+@|%(?:\\d+\\$)?[#0\\- +'\"]*(?:\\d+|\\*)?(?:\\.\\d+)?(?:hh|h|ll|l|z|t|L)?[a-zA-Z@]"
// return try! NSRegularExpression(pattern: pattern, options: [])
//}()
//
//private func placeholders(in string: String) -> [String] {
// let range = NSRange(location: 0, length: (string as NSString).length)
// let matches = placeholderRegex.matches(in: string, options: [], range: range)
// var tokens: [String] = []
// for match in matches {
// if let range = Range(match.range, in: string) {
// let token = String(string[range])
// if token == "%%" { continue }
// tokens.append(token)
// }
// }
// return tokens
//}
//
//private struct PrimaryKeyConfig: Decodable {
// let app: [String]
// let shareExtension: [String]
// let expectedValues: [String: [String: String]]?
// let testLocales: [String]?
//}
+26 -3
View File
@@ -13,6 +13,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
private let keychain: KeychainManagerProtocol
private var blockedFingerprints: Set<String> = []
private var blockedNostrPubkeys: Set<String> = []
private var socialIdentities: [String: SocialIdentity] = [:]
init(_ keychain: KeychainManagerProtocol) {
self.keychain = keychain
@@ -25,7 +26,7 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
func forceSave() {}
func getSocialIdentity(for fingerprint: String) -> SocialIdentity? {
nil
socialIdentities[fingerprint]
}
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) {}
@@ -34,7 +35,14 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
[]
}
func updateSocialIdentity(_ identity: SocialIdentity) {}
func updateSocialIdentity(_ identity: SocialIdentity) {
socialIdentities[identity.fingerprint] = identity
if identity.isBlocked {
blockedFingerprints.insert(identity.fingerprint)
} else {
blockedFingerprints.remove(identity.fingerprint)
}
}
func getFavorites() -> Set<String> {
Set()
@@ -47,10 +55,25 @@ final class MockIdentityManager: SecureIdentityStateManagerProtocol {
}
func isBlocked(fingerprint: String) -> Bool {
blockedFingerprints.contains(fingerprint)
blockedFingerprints.contains(fingerprint) || socialIdentities[fingerprint]?.isBlocked == true
}
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
if var identity = socialIdentities[fingerprint] {
identity.isBlocked = isBlocked
socialIdentities[fingerprint] = identity
} else {
let identity = SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: "",
trustLevel: .unknown,
isFavorite: false,
isBlocked: isBlocked,
notes: nil
)
socialIdentities[fingerprint] = identity
}
if isBlocked {
blockedFingerprints.insert(fingerprint)
} else {
+19
View File
@@ -184,6 +184,7 @@ final class MockTransport: Transport {
}
delegate?.didConnectToPeer(peerID)
delegate?.didUpdatePeerList(Array(connectedPeers))
publishPeerSnapshots()
}
/// Simulates a peer disconnecting
@@ -192,6 +193,7 @@ final class MockTransport: Transport {
peerNicknames.removeValue(forKey: peerID)
delegate?.didDisconnectFromPeer(peerID)
delegate?.didUpdatePeerList(Array(connectedPeers))
publishPeerSnapshots()
}
/// Simulates receiving a message
@@ -224,5 +226,22 @@ final class MockTransport: Transport {
/// Updates the peer snapshot publisher
func updatePeerSnapshots(_ snapshots: [TransportPeerSnapshot]) {
peerSnapshotSubject.send(snapshots)
Task { @MainActor [weak self] in
self?.peerEventsDelegate?.didUpdatePeerSnapshots(snapshots)
}
}
private func publishPeerSnapshots() {
let now = Date()
let snapshots = connectedPeers.map { peerID in
TransportPeerSnapshot(
peerID: peerID,
nickname: peerNicknames[peerID] ?? "",
isConnected: true,
noisePublicKey: Data(hexString: peerID.bare),
lastSeen: now
)
}
updatePeerSnapshots(snapshots)
}
}
+2 -2
View File
@@ -746,8 +746,8 @@ struct NoiseProtocolTests {
}
// Get transport ciphers
let (initSend, initRecv) = try initiatorHandshake.getTransportCiphers(useExtractedNonce: false)
let (respSend, respRecv) = try responderHandshake.getTransportCiphers(useExtractedNonce: false)
let (initSend, initRecv, _) = try initiatorHandshake.getTransportCiphers(useExtractedNonce: false)
let (respSend, respRecv, _) = try responderHandshake.getTransportCiphers(useExtractedNonce: false)
// Test transport messages (messages after the 3 handshake messages)
for index in 3..<testVector.messages.count {
@@ -0,0 +1,166 @@
//
// PublicMessagePipelineTests.swift
// bitchatTests
//
// Tests for PublicMessagePipeline ordering and deduplication.
//
import Testing
import Foundation
@testable import bitchat
@MainActor
private final class TestPipelineDelegate: PublicMessagePipelineDelegate {
private let dedupService = MessageDeduplicationService()
var messages: [BitchatMessage] = []
func pipelineCurrentMessages(_ pipeline: PublicMessagePipeline) -> [BitchatMessage] {
messages
}
func pipeline(_ pipeline: PublicMessagePipeline, setMessages messages: [BitchatMessage]) {
self.messages = messages
}
func pipeline(_ pipeline: PublicMessagePipeline, normalizeContent content: String) -> String {
dedupService.normalizedContentKey(content)
}
func pipeline(_ pipeline: PublicMessagePipeline, contentTimestampForKey key: String) -> Date? {
dedupService.contentTimestamp(forKey: key)
}
func pipeline(_ pipeline: PublicMessagePipeline, recordContentKey key: String, timestamp: Date) {
dedupService.recordContentKey(key, timestamp: timestamp)
}
func pipelineTrimMessages(_ pipeline: PublicMessagePipeline) {}
func pipelinePrewarmMessage(_ pipeline: PublicMessagePipeline, message: BitchatMessage) {}
func pipelineSetBatchingState(_ pipeline: PublicMessagePipeline, isBatching: Bool) {}
}
struct PublicMessagePipelineTests {
@Test @MainActor
func flush_sortsByTimestamp() async {
let pipeline = PublicMessagePipeline()
let delegate = TestPipelineDelegate()
pipeline.delegate = delegate
let earlier = Date().addingTimeInterval(-10)
let later = Date()
let messageA = BitchatMessage(
id: "a",
sender: "A",
content: "Later",
timestamp: later,
isRelay: false
)
let messageB = BitchatMessage(
id: "b",
sender: "A",
content: "Earlier",
timestamp: earlier,
isRelay: false
)
pipeline.enqueue(messageA)
pipeline.enqueue(messageB)
pipeline.flushIfNeeded()
#expect(delegate.messages.map { $0.id } == ["b", "a"])
}
@Test @MainActor
func flush_deduplicatesByContentWithinWindow() async {
let pipeline = PublicMessagePipeline()
let delegate = TestPipelineDelegate()
pipeline.delegate = delegate
let now = Date()
let messageA = BitchatMessage(
id: "a",
sender: "A",
content: "Same",
timestamp: now,
isRelay: false
)
let messageB = BitchatMessage(
id: "b",
sender: "A",
content: "Same",
timestamp: now.addingTimeInterval(0.2),
isRelay: false
)
pipeline.enqueue(messageA)
pipeline.enqueue(messageB)
pipeline.flushIfNeeded()
#expect(delegate.messages.count == 1)
#expect(delegate.messages.first?.content == "Same")
}
@Test @MainActor
func lateInsert_meshAppendsRecentOlderMessage() async {
let pipeline = PublicMessagePipeline()
let delegate = TestPipelineDelegate()
pipeline.delegate = delegate
pipeline.updateActiveChannel(.mesh)
let base = Date()
let newer = BitchatMessage(
id: "new",
sender: "A",
content: "New",
timestamp: base,
isRelay: false
)
let older = BitchatMessage(
id: "old",
sender: "A",
content: "Old",
timestamp: base.addingTimeInterval(-5),
isRelay: false
)
delegate.messages = [newer]
pipeline.enqueue(older)
pipeline.flushIfNeeded()
#expect(delegate.messages.map { $0.id } == ["new", "old"])
}
@Test @MainActor
func lateInsert_locationInsertsByTimestamp() async {
let pipeline = PublicMessagePipeline()
let delegate = TestPipelineDelegate()
pipeline.delegate = delegate
pipeline.updateActiveChannel(.location(GeohashChannel(level: .city, geohash: "u4pruydq")))
let base = Date()
let newer = BitchatMessage(
id: "new",
sender: "A",
content: "New",
timestamp: base,
isRelay: false
)
let older = BitchatMessage(
id: "old",
sender: "A",
content: "Old",
timestamp: base.addingTimeInterval(-5),
isRelay: false
)
delegate.messages = [newer]
pipeline.enqueue(older)
pipeline.flushIfNeeded()
#expect(delegate.messages.map { $0.id } == ["old", "new"])
}
}
@@ -0,0 +1,68 @@
//
// MessageRouterTests.swift
// bitchatTests
//
// Tests for MessageRouter transport selection and outbox behavior.
//
import Testing
import Foundation
@testable import bitchat
struct MessageRouterTests {
@Test @MainActor
func sendPrivate_usesReachableTransport() async {
let peerID = PeerID(str: "0000000000000001")
let transportA = MockTransport()
let transportB = MockTransport()
transportB.reachablePeers.insert(peerID)
let router = MessageRouter(transports: [transportA, transportB])
router.sendPrivate("Hello", to: peerID, recipientNickname: "Peer", messageID: "m1")
#expect(transportA.sentPrivateMessages.isEmpty)
#expect(transportB.sentPrivateMessages.count == 1)
}
@Test @MainActor
func sendPrivate_queuesThenFlushesWhenReachable() async {
let peerID = PeerID(str: "0000000000000002")
let transport = MockTransport()
let router = MessageRouter(transports: [transport])
router.sendPrivate("Queued", to: peerID, recipientNickname: "Peer", messageID: "m2")
#expect(transport.sentPrivateMessages.isEmpty)
transport.reachablePeers.insert(peerID)
router.flushOutbox(for: peerID)
#expect(transport.sentPrivateMessages.count == 1)
}
@Test @MainActor
func sendReadReceipt_usesReachableTransport() async {
let peerID = PeerID(str: "0000000000000003")
let transport = MockTransport()
transport.reachablePeers.insert(peerID)
let router = MessageRouter(transports: [transport])
let receipt = ReadReceipt(originalMessageID: "m3", readerID: transport.myPeerID, readerNickname: "Me")
router.sendReadReceipt(receipt, to: peerID)
#expect(transport.sentReadReceipts.count == 1)
}
@Test @MainActor
func sendFavoriteNotification_usesConnectedOrReachable() async {
let peerID = PeerID(str: "0000000000000004")
let transport = MockTransport()
transport.reachablePeers.insert(peerID)
let router = MessageRouter(transports: [transport])
router.sendFavoriteNotification(to: peerID, isFavorite: true)
#expect(transport.sentFavoriteNotifications.count == 1)
}
}
@@ -0,0 +1,72 @@
//
// PrivateChatManagerTests.swift
// bitchatTests
//
// Tests for PrivateChatManager read receipt and selection behavior.
//
import Testing
import Foundation
@testable import bitchat
struct PrivateChatManagerTests {
@Test @MainActor
func startChat_setsSelectedAndClearsUnread() async {
let transport = MockTransport()
let manager = PrivateChatManager(meshService: transport)
let peerID = PeerID(str: "00000000000000AA")
manager.privateChats[peerID] = [
BitchatMessage(
id: "pm-1",
sender: "Peer",
content: "Hi",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerID
)
]
manager.unreadMessages.insert(peerID)
manager.startChat(with: peerID)
#expect(manager.selectedPeer == peerID)
#expect(!manager.unreadMessages.contains(peerID))
#expect(manager.privateChats[peerID] != nil)
}
@Test @MainActor
func markAsRead_sendsReadReceiptViaRouter() async {
let transport = MockTransport()
let router = MessageRouter(transports: [transport])
let manager = PrivateChatManager(meshService: transport)
manager.messageRouter = router
let peerID = PeerID(str: "00000000000000BB")
transport.reachablePeers.insert(peerID)
manager.privateChats[peerID] = [
BitchatMessage(
id: "pm-2",
sender: "Peer",
content: "Hi",
timestamp: Date(),
isRelay: false,
isPrivate: true,
recipientNickname: "Me",
senderPeerID: peerID
)
]
manager.unreadMessages.insert(peerID)
manager.markAsRead(from: peerID)
try? await Task.sleep(nanoseconds: 100_000_000)
#expect(transport.sentReadReceipts.count == 1)
#expect(manager.sentReadReceipts.contains("pm-2"))
#expect(!manager.unreadMessages.contains(peerID))
}
}
@@ -0,0 +1,95 @@
//
// RelayControllerTests.swift
// bitchatTests
//
// Tests for relay decision logic.
//
import Testing
import Foundation
@testable import bitchat
struct RelayControllerTests {
@Test
func ttlOne_doesNotRelay() async {
let decision = RelayController.decide(
ttl: 1,
senderIsSelf: false,
isEncrypted: false,
isDirectedEncrypted: false,
isFragment: false,
isDirectedFragment: false,
isHandshake: false,
isAnnounce: false,
degree: 0,
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
)
#expect(!decision.shouldRelay)
#expect(decision.newTTL == 1)
}
@Test
func handshake_alwaysRelaysWithTTLDecrement() async {
let decision = RelayController.decide(
ttl: 3,
senderIsSelf: false,
isEncrypted: false,
isDirectedEncrypted: false,
isFragment: false,
isDirectedFragment: false,
isHandshake: true,
isAnnounce: false,
degree: 3,
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
)
#expect(decision.shouldRelay)
#expect(decision.newTTL == 2)
#expect(decision.delayMs >= 10 && decision.delayMs <= 35)
}
@Test
func fragment_relaysWithFragmentCap() async {
let decision = RelayController.decide(
ttl: 10,
senderIsSelf: false,
isEncrypted: false,
isDirectedEncrypted: false,
isFragment: true,
isDirectedFragment: false,
isHandshake: false,
isAnnounce: false,
degree: 3,
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
)
let ttlCap = min(UInt8(10), TransportConfig.bleFragmentRelayTtlCap)
let expected = ttlCap &- 1
#expect(decision.shouldRelay)
#expect(decision.newTTL == expected)
#expect(decision.delayMs >= TransportConfig.bleFragmentRelayMinDelayMs)
#expect(decision.delayMs <= TransportConfig.bleFragmentRelayMaxDelayMs)
}
@Test
func denseGraph_capsTTL() async {
let decision = RelayController.decide(
ttl: 10,
senderIsSelf: false,
isEncrypted: false,
isDirectedEncrypted: false,
isFragment: false,
isDirectedFragment: false,
isHandshake: false,
isAnnounce: false,
degree: TransportConfig.bleHighDegreeThreshold,
highDegreeThreshold: TransportConfig.bleHighDegreeThreshold
)
#expect(decision.shouldRelay)
#expect(decision.newTTL == 4)
}
}
@@ -0,0 +1,145 @@
//
// UnifiedPeerServiceTests.swift
// bitchatTests
//
// Tests for UnifiedPeerService fingerprint and block resolution.
//
import Testing
import Foundation
@testable import bitchat
struct UnifiedPeerServiceTests {
@Test @MainActor
func getFingerprint_prefersMeshService() async {
let transport = MockTransport()
let identity = TestIdentityManager()
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
let peerID = PeerID(str: "00000000000000CC")
transport.peerFingerprints[peerID] = "fp-1"
let fingerprint = service.getFingerprint(for: peerID)
#expect(fingerprint == "fp-1")
}
@Test @MainActor
func isBlocked_usesSocialIdentity() async {
let transport = MockTransport()
let identity = TestIdentityManager()
let idBridge = NostrIdentityBridge(keychain: MockKeychainHelper())
let service = UnifiedPeerService(meshService: transport, idBridge: idBridge, identityManager: identity)
let peerID = PeerID(str: "00000000000000DD")
let fingerprint = "fp-blocked"
transport.peerFingerprints[peerID] = fingerprint
identity.setBlocked(fingerprint, isBlocked: true)
#expect(service.isBlocked(peerID))
}
}
private final class TestIdentityManager: SecureIdentityStateManagerProtocol {
private var socialIdentities: [String: SocialIdentity] = [:]
private var favorites: Set<String> = []
private var blockedNostr: Set<String> = []
private var verified: Set<String> = []
func forceSave() {}
func getSocialIdentity(for fingerprint: String) -> SocialIdentity? {
socialIdentities[fingerprint]
}
func upsertCryptographicIdentity(fingerprint: String, noisePublicKey: Data, signingPublicKey: Data?, claimedNickname: String?) {}
func getCryptoIdentitiesByPeerIDPrefix(_ peerID: PeerID) -> [CryptographicIdentity] {
[]
}
func updateSocialIdentity(_ identity: SocialIdentity) {
socialIdentities[identity.fingerprint] = identity
}
func getFavorites() -> Set<String> {
favorites
}
func setFavorite(_ fingerprint: String, isFavorite: Bool) {
if isFavorite {
favorites.insert(fingerprint)
} else {
favorites.remove(fingerprint)
}
}
func isFavorite(fingerprint: String) -> Bool {
favorites.contains(fingerprint)
}
func isBlocked(fingerprint: String) -> Bool {
socialIdentities[fingerprint]?.isBlocked ?? false
}
func setBlocked(_ fingerprint: String, isBlocked: Bool) {
var identity = socialIdentities[fingerprint] ?? SocialIdentity(
fingerprint: fingerprint,
localPetname: nil,
claimedNickname: "",
trustLevel: .unknown,
isFavorite: false,
isBlocked: false,
notes: nil
)
identity.isBlocked = isBlocked
socialIdentities[fingerprint] = identity
}
func isNostrBlocked(pubkeyHexLowercased: String) -> Bool {
blockedNostr.contains(pubkeyHexLowercased)
}
func setNostrBlocked(_ pubkeyHexLowercased: String, isBlocked: Bool) {
if isBlocked {
blockedNostr.insert(pubkeyHexLowercased)
} else {
blockedNostr.remove(pubkeyHexLowercased)
}
}
func getBlockedNostrPubkeys() -> Set<String> {
blockedNostr
}
func registerEphemeralSession(peerID: PeerID, handshakeState: HandshakeState) {}
func updateHandshakeState(peerID: PeerID, state: HandshakeState) {}
func clearAllIdentityData() {
socialIdentities.removeAll()
favorites.removeAll()
blockedNostr.removeAll()
verified.removeAll()
}
func removeEphemeralSession(peerID: PeerID) {}
func setVerified(fingerprint: String, verified: Bool) {
if verified {
self.verified.insert(fingerprint)
} else {
self.verified.remove(fingerprint)
}
}
func isVerified(fingerprint: String) -> Bool {
verified.contains(fingerprint)
}
func getVerifiedFingerprints() -> Set<String> {
verified
}
}
@@ -75,7 +75,6 @@ struct SubscriptionRateLimitTests {
@Test("Max attempts threshold prevents complete enumeration")
func maxAttemptsThresholdPreventsEnumeration() {
let maxAttempts = TransportConfig.bleSubscriptionRateLimitMaxAttempts
let minInterval = TransportConfig.bleSubscriptionRateLimitMinSeconds
// After max attempts within window, announces are suppressed entirely
// This means an attacker gets at most maxAttempts announces per window
@@ -93,6 +93,22 @@ final class TestHelpers {
try await sleep(0.01)
}
}
@MainActor
static func waitUntil(
_ condition: @escaping () -> Bool,
timeout: TimeInterval = TestConstants.defaultTimeout,
pollInterval: TimeInterval = 0.01
) async -> Bool {
let start = Date()
while !condition() {
if Date().timeIntervalSince(start) > timeout {
return condition()
}
try? await sleep(pollInterval)
}
return true
}
static func expectAsync<T>(
timeout: TimeInterval = TestConstants.defaultTimeout,
+48
View File
@@ -0,0 +1,48 @@
# Request Sync Manager & V2 Packet Updates
This document details the implementation of the Request Sync Manager and updates to the V2 packet structure to improve synchronization security and attribution on iOS, mirroring the Android implementation.
## Overview
The goal of these changes is to make the request sync functionality "less blind". Previously, sync requests were broadcast, and responses were accepted without strict attribution or timestamp validation (to allow syncing old messages). This opened up potential spoofing vectors and prevented us from enforcing timestamp checks on normal traffic.
The new implementation introduces a **RequestSyncManager** to track outgoing sync requests and attributes incoming responses (RSR - Request-Sync Response) to specific peers. This allows us to:
1. **Enforce Timestamp Validation**: Normal packets now require timestamps to be within 2 minutes of the local clock.
2. **Exempt Solicited Sync Responses**: Packets marked as RSR are exempt from timestamp validation *only if* they correspond to a valid, pending sync request sent to that specific peer.
3. **Prevent Unsolicited Sync Floods**: Unsolicited RSR packets are rejected.
## Protocol Changes
### Binary Protocol Updates
* **New Flag**: `IS_RSR` (0x10) added to the packet header flags.
* **BitchatPacket**: Updated to include `isRSR: Bool` field.
* **Encoding/Decoding**: Updated `BinaryProtocol` to handle the new flag.
### Request Sync Payload
The `REQUEST_SYNC` packet payload (TLV encoded) has been updated to include:
* **Future Filters**:
* `sinceTimestamp` (Type 0x05): To request packets since a certain time (UInt64 big-endian).
* `fragmentIdFilter` (Type 0x06): To request specific fragments (UTF-8 string).
## Architecture
### RequestSyncManager
A new component (`Sync/RequestSyncManager.swift`) responsible for:
* **Tracking**: Stores `peerID -> timestamp` mappings for pending sync requests.
* **Validation**: `isValidResponse(from: PeerID, isRSR: Bool)` checks if an incoming RSR packet matches a pending request within the 30-second window.
* **Cleanup**: Periodically removes expired requests.
### GossipSyncManager Updates
* **Unicast Sync**: Instead of blind broadcasting, the periodic sync task now iterates over connected peers and sends unicast `REQUEST_SYNC` packets.
* **Registration**: Before sending, requests are registered with `RequestSyncManager`.
* **Response Marking**: When responding to a `REQUEST_SYNC`, generated packets (Announce/Message) are explicitly marked with `isRSR = true` (and `ttl = 0`).
### BLEService (Security Manager) Updates
* **Timestamp Enforcement**: Checks `abs(now - packetTimestamp) < 2 minutes` for standard packets.
* **Conditional Exemption**: If `packet.isRSR` is true (or packet is a legacy TTL=0 response), it queries `RequestSyncManager`.
* **Valid**: If solicited, timestamp check is skipped (allowing historical data sync).
* **Invalid**: If unsolicited or timed out, the packet is rejected.
## Usage
These changes are integrated into `BLEService` and `GossipSyncManager`. No external API changes are required for clients, but all peers must be updated to support the new `IS_RSR` flag and protocol logic to participate in the secure sync process.
@@ -19,4 +19,5 @@ public extension OSLog {
static let session = OSLog(subsystem: subsystem, category: "session")
static let security = OSLog(subsystem: subsystem, category: "security")
static let handshake = OSLog(subsystem: subsystem, category: "handshake")
static let sync = OSLog(subsystem: subsystem, category: "sync")
}
+271 -269
View File
@@ -1,269 +1,271 @@
Relay URL,Latitude,Longitude
relay-admin.thaliyal.com,40.8218,-74.45
nostr.notribe.net,40.8302,-74.1299
strfry.bonsai.com,37.8715,-122.273
nostr-relay.online,40.7357,-74.1724
shu05.shugur.net,48.8566,2.35222
dev-nostr.bityacht.io,25.0797,121.234
relay.nostrhub.tech,49.0291,8.35696
relay.davidebtc.me,50.1109,8.68213
relay.moinsen.com,50.4754,12.3683
relay.olas.app,50.4754,12.3683
mhp258zrpiiwn.clorecloud.net,43.6532,-79.3832
orangepiller.org,60.1699,24.9384
relayrs.notoshi.win,43.6532,-79.3832
relay.guggero.org,47.3769,8.54169
nostr.blankfors.se,60.1699,24.9384
wot.sovbit.host,64.1466,-21.9426
nostr.huszonegy.world,47.4979,19.0402
wot.sebastix.social,51.8933,4.42083
articles.layer3.news,37.3387,-121.885
nostr.spicyz.io,40.7357,-74.1724
nostr.jerrynya.fun,31.2304,121.474
nostr.oxtr.dev,50.4754,12.3683
relay.mwaters.net,50.9871,2.12554
vitor.nostr1.com,40.7128,-74.006
relay.lumina.rocks,49.0291,8.35695
nostr-relay-1.trustlessenterprise.com,40.7357,-74.1724
nostr.bilthon.dev,25.8128,-80.2377
nostr.now,36.55,139.733
nostr.girino.org,40.7357,-74.1724
nostr-01.yakihonne.com,1.32123,103.695
ribo.af.nostria.app,-26.2041,28.0473
nostrelay.memory-art.xyz,43.6532,-79.3832
relay.evanverma.com,40.8302,-74.1299
a.nos.lol,50.4754,12.3683
purpura.cloud,40.7357,-74.1724
relay.snort.social,43.6532,-79.3832
relay.holzeis.me,43.6532,-79.3832
nostr.spaceshell.xyz,40.7128,-74.006
nostr.liberty.fans,36.9104,-89.5875
relay.fundstr.me,42.3601,-71.0589
wot.basspistol.org,49.4521,11.0767
ribo.eu.nostria.app,52.3676,4.90414
relay.notoshi.win,13.4166,101.335
relay.stream.labs.h3.se,59.4016,17.9455
nostr.stakey.net,52.3676,4.90414
relay.satlantis.io,32.8769,-80.0114
nostr-2.21crypto.ch,47.4988,8.72369
nostr.satstralia.com,64.1476,-21.9392
slick.mjex.me,39.048,-77.4817
nostr-relay.nextblockvending.com,47.2343,-119.853
relay.origin.land,35.6673,139.751
nostr.fbxl.net,48.382,-89.2502
relay.nostr.place,32.7767,-96.797
nr.yay.so,46.2126,6.1154
nostream.breadslice.com,40.7357,-74.1724
wot.tealeaf.dev,33.7488,-84.3877
relay.primal.net,43.6532,-79.3832
relay.chorus.community,50.1109,8.68213
wot.dergigi.com,64.1476,-21.9392
nostr-relay.amethyst.name,39.0438,-77.4874
nostr.mehdibekhtaoui.com,49.4939,-1.54813
relay.mess.ch,46.948,7.44745
relay.sigit.io,50.4754,12.3683
relay-rpi.edufeed.org,49.4543,11.0746
nostr.faultables.net,43.6532,-79.3832
relay.getsafebox.app,43.6532,-79.3832
cyberspace.nostr1.com,40.7128,-74.006
relay.endfiat.money,43.6532,-79.3832
soloco.nl,43.6532,-79.3832
nostr.kungfu-g.rip,33.7946,-84.4488
nostrelay.circum.space,51.2217,6.77616
relay.trustroots.org,43.6532,-79.3832
relay.wellorder.net,45.5201,-122.99
relay.coinos.io,40.7357,-74.1724
relay-testnet.k8s.layer3.news,37.3387,-121.885
relay.nostriot.com,41.5695,-83.9786
relay.bitcoinartclock.com,50.4754,12.3683
nostr.einundzwanzig.space,50.1109,8.68213
nostr.casa21.space,43.6532,-79.3832
premium.primal.net,40.7357,-74.1724
relay.tagayasu.xyz,43.6715,-79.38
nostr.mom,50.4754,12.3683
nostr.zenon.network,43.5009,-70.4428
nostr-pub.wellorder.net,45.5201,-122.99
relay.g1sms.fr,43.9432,2.07537
nostr-rs-relay-ishosta.phamthanh.me,40.7357,-74.1724
relay.illuminodes.com,47.6061,-122.333
dizzyspells.nostr1.com,40.7057,-74.0136
relay.mostro.network,40.8302,-74.1299
relay.nostr.wirednet.jp,34.706,135.493
relay.barine.co,43.6532,-79.3832
relay.damus.io,43.6532,-79.3832
relay.0xchat.com,1.35208,103.82
relay.mattybs.lol,40.7357,-74.1724
no.str.cr,9.92857,-84.0528
relay.utxo.farm,35.6916,139.768
nostr.pleb.one,38.6327,-90.1961
relay-dev.satlantis.io,40.8302,-74.1299
relay.nostrdice.com,-33.8688,151.209
relay.nostraddress.com,40.7357,-74.1724
satsage.xyz,37.3986,-121.964
offchain.pub,36.1809,-115.241
noxir.kpherox.dev,34.8587,135.509
nostr-relay.psfoundation.info,39.0438,-77.4874
khatru.nostrver.se,51.8933,4.42083
purplerelay.com,50.1109,8.68213
relay.tapestry.ninja,40.8054,-74.0241
nostr.night7.space,50.4754,12.3683
nostr.rikmeijer.nl,50.4754,12.3683
relay1.nostrchat.io,60.1699,24.9384
nostr.21crypto.ch,47.4988,8.72369
wot.soundhsa.com,33.1384,-95.6011
relay.orangepill.ovh,49.1689,-0.358841
talon.quest,43.6532,-79.3832
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
wot.codingarena.top,50.4754,12.3683
fanfares.nostr1.com,40.7128,-74.006
gnostr.com,42.6978,23.3246
nostrcheck.tnsor.network,40.7357,-74.1724
nostrelites.org,41.8781,-87.6298
relay.bitcoindistrict.org,43.6532,-79.3832
relay.fr13nd5.com,52.5233,13.3426
wot.nostr.place,30.2672,-97.7431
ithurtswhenip.ee,51.223,6.78245
relay.dwadziesciajeden.pl,52.2297,21.0122
relay2.ngengine.org,40.7357,-74.1724
relay.nostr.net,50.4754,12.3683
nostr-relay.cbrx.io,40.7357,-74.1724
dev-relay.lnfi.network,39.0997,-94.5786
relay.jeffg.fyi,43.6532,-79.3832
relay.ngengine.org,40.7357,-74.1724
nos.xmark.cc,50.6924,3.20113
relay.21e6.cz,50.1682,14.0546
relay.degmods.com,50.4754,12.3683
nostr.coincrowd.fund,39.0438,-77.4874
nostr.myshosholoza.co.za,52.3676,4.90414
relay.digitalezukunft.cyou,45.5019,-73.5674
r.lostr.net,52.3676,4.90414
relay.etch.social,41.2619,-95.8608
nostr.tac.lol,47.4748,-122.273
nostr.azzamo.net,52.2633,21.0283
nostr.4rs.nl,49.0291,8.35696
nostr-03.dorafactory.org,1.35208,103.82
relay.copylaradio.com,51.223,6.78245
nostr.camalolo.com,24.1469,120.684
nostr-dev.wellorder.net,45.5201,-122.99
relay.nostx.io,43.6532,-79.3832
r.bitcoinhold.net,43.6532,-79.3832
nproxy.kristapsk.lv,60.1699,24.9384
adre.su,59.9311,30.3609
relay.hasenpfeffr.com,39.0438,-77.4874
nos.lol,50.4754,12.3683
relay.nostr.band,60.1699,24.9384
nostr-02.czas.top,53.471,9.88208
relay.nosto.re,51.8933,4.42083
nostr.plantroon.com,50.1013,8.62643
nostr.rblb.it,43.4633,11.8796
nostr.thebiglake.org,32.71,-96.6745
nostr.luisschwab.net,40.7357,-74.1724
relay.electriclifestyle.com,26.2897,-80.1293
librerelay.aaroniumii.com,43.6532,-79.3832
nostr.88mph.life,40.7357,-74.1724
shu02.shugur.net,21.4902,39.2246
relay.hook.cafe,40.7357,-74.1724
strfry.elswa-dev.online,48.8566,2.35222
wot.sudocarlos.com,51.5072,-0.127586
relay.islandbitcoin.com,12.8498,77.6545
nostr.tadryanom.me,40.7357,-74.1724
relay.zone667.com,60.1699,24.9384
nostr.agentcampfire.com,50.8933,6.05805
relay.ditto.pub,40.7357,-74.1724
relay03.lnfi.network,39.0997,-94.5786
relay2.angor.io,48.1046,11.6002
srtrelay.c-stellar.net,40.7357,-74.1724
relayone.soundhsa.com,33.1384,-95.6011
relay.javi.space,43.4633,11.8796
nostr.carroarmato0.be,50.9928,3.26317
nostr.hekster.org,37.3986,-121.964
strfry.shock.network,41.8959,-88.2169
nostr.2b9t.xyz,34.0549,-118.243
relay.toastr.net,40.8054,-74.0241
relay.bitcoinveneto.org,64.1466,-21.9426
relay.wavlake.com,41.2619,-95.8608
relay.arx-ccn.com,50.4754,12.3683
relay.cosmicbolt.net,37.3986,-121.964
relay.mccormick.cx,52.3563,4.95714
temp.iris.to,40.7357,-74.1724
relay.vrtmrz.net,40.7357,-74.1724
nostr-relay.zimage.com,34.282,-118.439
nostr.data.haus,50.4754,12.3683
ribo.us.nostria.app,41.5868,-93.625
nostr.vulpem.com,49.4543,11.0746
relay.agora.social,50.7383,15.0648
nostr.ovia.to,43.6532,-79.3832
nostr.red5d.dev,40.7357,-74.1724
orangesync.tech,50.1109,8.68213
relay.fountain.fm,39.0997,-94.5786
relay.aloftus.io,34.0881,-118.379
nostr.hifish.org,47.4043,8.57398
relay.siamdev.cc,13.9178,100.424
fenrir-s.notoshi.win,43.6532,-79.3832
nostr.overmind.lol,43.6532,-79.3832
wheat.happytavern.co,40.7357,-74.1724
nostr.rtvslawenia.com,49.4543,11.0746
relay.nostrhub.fr,48.1046,11.6002
strfry.openhoofd.nl,51.9229,4.40833
relay.usefusion.ai,38.7134,-78.1591
relay.credenso.cafe,43.3601,-80.3127
nostr.lostr.space,40.7357,-74.1724
relay.jmoose.rocks,60.1699,24.9384
relay.nostromo.social,49.4543,11.0746
nostr.jfischer.org,49.0291,8.35696
relay.wolfcoil.com,35.6092,139.73
nostr.thaliyal.com,40.8218,-74.45
relay.magiccity.live,25.8128,-80.2377
relay.puresignal.news,40.7357,-74.1724
prl.plus,55.7623,37.6381
wot.brightbolt.net,47.6735,-116.781
relay.varke.eu,52.6921,6.19372
alienos.libretechsystems.xyz,55.4724,9.87335
relay.goodmorningbitcoin.com,43.6532,-79.3832
pyramid.fiatjaf.com,51.5072,-0.127586
relay02.lnfi.network,39.0997,-94.5786
nostr.davidebtc.me,50.1109,8.68213
nostr-verified.wellorder.net,45.5201,-122.99
relay.cypherflow.ai,48.8566,2.35222
nostr.snowbla.de,60.1699,24.9384
inbox.azzamo.net,52.2633,21.0283
shu01.shugur.net,21.4902,39.2246
nostr.middling.mydns.jp,35.8099,140.12
nostr.kalf.org,52.3676,4.90414
relay.laantungir.net,-19.4692,-42.5315
relay.angor.io,48.1046,11.6002
nostr2.girino.org,40.7357,-74.1724
relay01.lnfi.network,39.0997,-94.5786
nostr.chaima.info,51.223,6.78245
x.kojira.io,43.6532,-79.3832
shu04.shugur.net,25.2604,55.2989
santo.iguanatech.net,40.8302,-74.1299
relay.artx.market,43.652,-79.3633
alien.macneilmediagroup.com,40.7357,-74.1724
nostr.sathoarder.com,48.5734,7.75211
zap.watch,45.5029,-73.5723
relay.basspistol.org,46.2044,6.14316
relay.13room.space,43.6532,-79.3832
relay.bullishbounty.com,40.7357,-74.1724
theoutpost.life,64.1476,-21.9392
nostr.coincards.com,53.5501,-113.469
black.nostrcity.club,41.8781,-87.6298
relay.npubhaus.com,40.7357,-74.1724
relay.freeplace.nl,52.3676,4.90414
relay.seq1.net,43.6532,-79.3832
ynostr.yael.at,60.1699,24.9384
relay.nostr.vet,52.6467,4.7395
relay.lifpay.me,1.35208,103.82
relay.chakany.systems,43.6532,-79.3832
relay.lightning.pub,41.8959,-88.2169
wot.dtonon.com,43.6532,-79.3832
yabu.me,35.6092,139.73
wot.nostr.net,43.6532,-79.3832
relay.libernet.app,40.7357,-74.1724
relay04.lnfi.network,39.0997,-94.5786
nostr.0x7e.xyz,47.4988,8.72369
nostr.mikoshi.de,50.1109,8.68213
wot.nostr.party,36.1627,-86.7816
relay.letsfo.com,51.098,17.0321
nostr.makibisskey.work,43.6532,-79.3832
nostr.simplex.icu,50.8198,-1.08798
Relay URL,Latitude,Longitude
relay.angor.io,48.1046,11.6002
black.nostrcity.club,48.8566,2.35222
relay.damus.io,43.6532,-79.3832
relay.satmaxt.xyz,43.6532,-79.3832
articles.layer3.news,37.3387,-121.885
relay.nuts.cash,34.0362,-118.443
lightning.red,53.3498,-6.26031
relay.davidebtc.me,43.6532,-79.3832
nostr-02.czas.top,51.2277,6.77346
inbox.azzamo.net,52.2633,21.0283
relay.primal.net,43.6532,-79.3832
relay-testnet.k8s.layer3.news,37.3387,-121.885
nostr.21crypto.ch,47.5356,8.73209
r.bitcoinhold.net,43.6532,-79.3832
nostr.self-determined.de,54.5847,10.0178
relay.fr13nd5.com,52.5233,13.3426
wons.calva.dev,37.3986,-121.964
pyramid-relay.meows.lol,38.7475,-77.5317
relay.pinseekr.golf,43.6532,-79.3832
nostr-relay.online,43.6532,-79.3832
relay.evanverma.com,40.8302,-74.1299
relay.nostr.wirednet.jp,34.706,135.493
nostr.spicyz.io,43.6532,-79.3832
ynostr.yael.at,60.1699,24.9384
testrelay.era21.space,43.6532,-79.3832
nostr.islandarea.net,35.4669,-97.6473
purpura.cloud,43.6532,-79.3832
testnet-relay.samt.st,40.8302,-74.1299
relay.sigit.io,50.4754,12.3683
nostr.jerrynya.fun,31.2304,121.474
relay.bitcoindistrict.org,43.6532,-79.3832
nostr.ps1829.com,33.8851,130.883
nostr-2.21crypto.ch,47.5356,8.73209
wheat.happytavern.co,43.6532,-79.3832
plebchain.club,43.6532,-79.3832
relay2.ngengine.org,43.6532,-79.3832
relay01.lnfi.network,39.0997,-94.5786
chat-relay.zap-work.com,43.6532,-79.3832
relay.21e6.cz,50.7383,15.0648
relay.erybody.com,41.4513,-81.7021
nostr.commonshub.brussels,49.4543,11.0746
nostr.thebiglake.org,32.71,-96.6745
nostr-pub.wellorder.net,45.5201,-122.99
relay2.angor.io,48.1046,11.6002
relay.ru.ac.th,13.7607,100.627
relay.threenine.services,51.5524,-0.29686
nostr-relay-1.trustlessenterprise.com,43.6532,-79.3832
nostr.zoracle.org,45.6018,-121.185
nostr-dev.wellorder.net,45.5201,-122.99
relay.minibolt.info,43.6532,-79.3832
nostr-relay.nextblockvending.com,47.2343,-119.853
relay.satlantis.io,32.8769,-80.0114
relay.malxte.de,52.52,13.405
nostr.nodesmap.com,59.3327,18.0656
relay.mccormick.cx,52.3563,4.95714
nostr-relay.xbytez.io,50.6924,3.20113
nostr-relay.zimage.com,34.0549,-118.243
schnorr.me,43.6532,-79.3832
relay.nostr.place,32.7767,-96.797
shu05.shugur.net,48.8566,2.35222
nostrja-kari.heguro.com,43.6532,-79.3832
relay.getsafebox.app,43.6532,-79.3832
nostr.tac.lol,47.4748,-122.273
relay.cosmicbolt.net,37.3986,-121.964
nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832
nostr-relayrs.gateway.in.th,15.2634,100.344
relay.nostrverse.net,43.6532,-79.3832
wot.dergigi.com,64.1476,-21.9392
fanfares.nostr1.com,40.7057,-74.0136
relay.bitcoinartclock.com,50.4754,12.3683
0m0sef4nb45q6.cloreai.ru,43.6532,-79.3832
relay.btcforplebs.com,43.6532,-79.3832
relay.zone667.com,60.1699,24.9384
nostrcheck.tnsor.network,43.6532,-79.3832
nostr-relay.amethyst.name,39.0438,-77.4874
relay.lightning.pub,39.0438,-77.4874
nostr-verified.wellorder.net,45.5201,-122.99
relay.agorist.space,52.3734,4.89406
nostr-relay.cbrx.io,43.6532,-79.3832
nostr.overmind.lol,43.6532,-79.3832
relay.jeffg.fyi,43.6532,-79.3832
relay.seq1.net,43.6532,-79.3832
relay.lumina.rocks,49.0291,8.35695
relay.samt.st,40.8302,-74.1299
nostr.rtvslawenia.com,49.4543,11.0746
nostr.red5d.dev,43.6532,-79.3832
slick.mjex.me,39.048,-77.4817
nostr.vulpem.com,49.4543,11.0746
relay-freeharmonypeople.space,38.7223,-9.13934
relay.routstr.com,43.6532,-79.3832
garden.zap.cooking,40.9481,-79.7428
relay.nostrops.com,33.7584,-84.6375
spookstr2.nostr1.com,40.7128,-74.006
relay.arx-ccn.com,50.4754,12.3683
relay.coinos.io,43.6532,-79.3832
shu02.shugur.net,21.4902,39.2246
nostr.night7.space,50.4754,12.3683
nostr.casa21.space,43.6532,-79.3832
nostr.bgbitcoin.club,50.4754,12.3683
strfry.felixzieger.de,50.1013,8.62643
wot.brightbolt.net,47.6735,-116.781
freelay.sovbit.host,64.1476,-21.9392
relay-dev.satlantis.io,40.8302,-74.1299
relay.vantis.ninja,43.6532,-79.3832
relay.cyphernomad.com,60.1699,24.9384
relay.thebluepulse.com,49.4521,11.0767
fenrir-s.notoshi.win,43.6532,-79.3832
nostr.rblb.it,43.7094,10.6582
relay.goodmorningbitcoin.com,43.6532,-79.3832
wot.sebastix.social,51.1792,5.89444
wotr.relatr.xyz,53.3498,-6.26031
nostr.mehdibekhtaoui.com,49.4939,-1.54813
relay.snort.social,53.3498,-6.26031
librerelay.aaroniumii.com,43.6532,-79.3832
vault.iris.to,43.6532,-79.3832
relay.bullishbounty.com,43.6532,-79.3832
nostr.myshosholoza.co.za,52.3676,4.90414
adre.su,59.8845,30.3184
nostr.quali.chat,60.1699,24.9384
dev-relay.lnfi.network,39.0997,-94.5786
nostr-03.dorafactory.org,1.35208,103.82
relay.nostriot.com,41.5695,-83.9786
ragnar.nostrops.com,33.7946,-84.4488
kitchen.zap.cooking,43.6532,-79.3832
nos4smartnkind.tech,40.1872,44.5152
nostr-relay.psfoundation.info,39.0438,-77.4874
relay.origin.land,35.6673,139.751
new.orly.dev,55.9349,23.3137
strfry.ymir.cloud,34.0965,-117.585
ribo.eu.nostria.app,52.3676,4.90414
nostr.bilthon.dev,25.7975,-80.23
bcast.girino.org,43.6532,-79.3832
yabu.me,35.6092,139.73
nostr.blankfors.se,60.1699,24.9384
relay.guggero.org,47.3769,8.54169
shu03.shugur.net,25.2048,55.2708
temp.iris.to,43.6532,-79.3832
r.alphaama.com,60.1699,24.9384
nostr.mom,50.4754,12.3683
nostr.oxtr.dev,50.4754,12.3683
nostr.faultables.net,43.6532,-79.3832
relay.internationalright-wing.org,-22.5022,-48.7114
nostr.bitczat.pl,60.1699,24.9384
relay.endfiat.money,43.6532,-79.3832
nostr.luisschwab.net,43.6532,-79.3832
nostr-relay.gateway.in.th,15.2634,100.344
nostr-02.yakihonne.com,1.32123,103.695
nostr.sathoarder.com,48.5734,7.75211
strfry.shock.network,39.0438,-77.4874
wot.nostr.place,32.7767,-96.797
nostr.azzamo.net,52.2633,21.0283
nostr.hekster.org,37.3986,-121.964
czas.xyz,48.8566,2.35222
premium.primal.net,43.6532,-79.3832
pyramid.aaro.cc,32.7767,-96.797
purplerelay.com,50.1109,8.68213
nostr.stakey.net,52.3676,4.90414
nostr.girino.org,43.6532,-79.3832
relay.agora.social,50.7383,15.0648
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
relay.nostrzh.org,43.6532,-79.3832
nostr.n7ekb.net,47.4941,-122.294
relay.fountain.fm,43.6532,-79.3832
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
nostr.mifen.me,43.6532,-79.3832
relay.anmore.me,49.704,-124.918
kotukonostr.onrender.com,37.7775,-122.397
relay.0xchat.com,1.35208,103.82
bitsat.molonlabe.holdings,51.4012,-1.3147
relay.layer.systems,49.0291,8.35695
api.freefrom.space/v1/ws,43.6532,-79.3832
relay.nostrdice.com,-33.8688,151.209
relay.nostrhub.fr,48.1045,11.6004
bbw-nostr.xyz,41.5284,-87.4237
relay.npubhaus.com,43.6532,-79.3832
relay.artx.market,43.6532,-79.3832
nostr.snowbla.de,60.1699,24.9384
relay.bnos.space,43.6532,-79.3832
wot.nostr.party,36.1512,-86.7835
relayrs.notoshi.win,43.6532,-79.3832
strfry.openhoofd.nl,51.9229,4.40833
nostr.notribe.net,40.8302,-74.1299
relay.libernet.app,43.6532,-79.3832
relay.chakany.systems,43.6532,-79.3832
nostr.thaliyal.com,40.8218,-74.45
relay.ryzizub.com,43.6532,-79.3832
relay.nostx.io,43.6532,-79.3832
bcast.seutoba.com.br,43.6532,-79.3832
nostrelay.circum.space,52.3676,4.90414
relay.janx.com,41.2054,-76.0049
relay.fundstr.me,42.3601,-71.0589
relay.toastr.net,40.8054,-74.0241
relay.puresignal.news,43.6532,-79.3832
relay.magiccity.live,25.7975,-80.23
satsage.xyz,37.3986,-121.964
nostr.now,36.55,139.733
offchain.pub,47.6743,-117.112
relay-rpi.edufeed.org,49.4521,11.0767
nostr.chaima.info,50.1109,8.68213
freeben666.fr,43.7221,7.15296
relay.siamdev.cc,13.8434,100.363
wot.nostr.net,43.6532,-79.3832
relay.ditto.pub,43.6532,-79.3832
relay.ngengine.org,43.6532,-79.3832
relay.tagayasu.xyz,45.4215,-75.6972
wot.yesnostr.net,50.9871,2.12554
relay.wavefunc.live,34.0362,-118.443
relay.snotr.nl:49999,52.0067,4.35556
nostr.dler.com,25.0501,121.565
relay.nostr.net,43.6532,-79.3832
relay.comcomponent.com,34.7062,135.493
relay.nosto.re,51.1792,5.89444
nostr2.girino.org,43.6532,-79.3832
wot.sudocarlos.com,43.6532,-79.3832
nostr.robosats.org,64.1476,-21.9392
strfry.elswa-dev.online,50.1109,8.68213
nos.lol,50.4754,12.3683
aaa-api.freefrom.space/v1/ws,43.6532,-79.3832
relay.mitchelltribe.com,39.0438,-77.4874
prl.plus,42.6978,23.3246
relay.illuminodes.com,47.6061,-122.333
srtrelay.c-stellar.net,43.6532,-79.3832
nostrelites.org,41.8781,-87.6298
nostr.na.social,43.6532,-79.3832
nostr.tadryanom.me,43.6532,-79.3832
okn.czas.plus,50.1109,8.68213
relay03.lnfi.network,39.0997,-94.5786
cyberspace.nostr1.com,40.7128,-74.006
notemine.io,52.2026,20.9397
wot.sovbit.host,64.1466,-21.9426
santo.iguanatech.net,40.8302,-74.1299
relay.degmods.com,50.4754,12.3683
relay.chorus.community,50.1109,8.68213
relay.orangepill.ovh,49.1689,-0.358841
wot.dtonon.com,43.6532,-79.3832
nostr.parallel.hetu.org:8443,1.35208,103.82
discovery.eu.nostria.app,52.3676,4.90414
nostrcheck.me,43.6532,-79.3832
relay-arg.zombi.cloudrodion.com,1.35208,103.82
espelho.girino.org,43.6532,-79.3832
nostr.superfriends.online,43.6532,-79.3832
relay.keykeeper.world,40.7824,-74.0711
nostr.ovia.to,43.6532,-79.3832
x.kojira.io,43.6532,-79.3832
bucket.coracle.social,37.7775,-122.397
nostr.data.haus,50.4754,12.3683
nostr-relay.corb.net,38.8353,-104.822
relay.credenso.cafe,43.3601,-80.3127
relayone.soundhsa.com,33.1384,-95.6011
relay.notoshi.win,14.2046,101.213
relay.olas.app,50.4754,12.3683
nostr.mikoshi.de,47.8786,11.911
relay.mostro.network,40.8302,-74.1299
nostr.noones.com,50.1109,8.68213
nostr.4rs.nl,49.0291,8.35696
strfry.bonsai.com,37.8715,-122.273
nostr.spaceshell.xyz,43.6532,-79.3832
relay.contextvm.org,53.3498,-6.26031
relay.westernbtc.com,44.5401,-123.368
soloco.nl,43.6532,-79.3832
relay.nostar.org,43.6532,-79.3832
nostr.lkjsxc.com,43.6532,-79.3832
theoutpost.life,64.1476,-21.9392
relay.javi.space,43.4633,11.8796
khatru.nostrver.se,51.1792,5.89444
relay.nostrcheck.me,43.6532,-79.3832
nostr.bond,50.1109,8.68213
relay.cypherflow.ai,48.8566,2.35222
relay.wellorder.net,45.5201,-122.99
wot.shaving.kiwi,43.6532,-79.3832
1 Relay URL Latitude Longitude
2 relay-admin.thaliyal.com relay.angor.io 40.8218 48.1046 -74.45 11.6002
3 nostr.notribe.net black.nostrcity.club 40.8302 48.8566 -74.1299 2.35222
4 strfry.bonsai.com relay.damus.io 37.8715 43.6532 -122.273 -79.3832
5 nostr-relay.online relay.satmaxt.xyz 40.7357 43.6532 -74.1724 -79.3832
6 shu05.shugur.net articles.layer3.news 48.8566 37.3387 2.35222 -121.885
7 dev-nostr.bityacht.io relay.nuts.cash 25.0797 34.0362 121.234 -118.443
8 relay.nostrhub.tech lightning.red 49.0291 53.3498 8.35696 -6.26031
9 relay.davidebtc.me 50.1109 43.6532 8.68213 -79.3832
10 relay.moinsen.com nostr-02.czas.top 50.4754 51.2277 12.3683 6.77346
11 relay.olas.app inbox.azzamo.net 50.4754 52.2633 12.3683 21.0283
12 mhp258zrpiiwn.clorecloud.net relay.primal.net 43.6532 -79.3832
13 orangepiller.org relay-testnet.k8s.layer3.news 60.1699 37.3387 24.9384 -121.885
14 relayrs.notoshi.win nostr.21crypto.ch 43.6532 47.5356 -79.3832 8.73209
15 relay.guggero.org r.bitcoinhold.net 47.3769 43.6532 8.54169 -79.3832
16 nostr.blankfors.se nostr.self-determined.de 60.1699 54.5847 24.9384 10.0178
17 wot.sovbit.host relay.fr13nd5.com 64.1466 52.5233 -21.9426 13.3426
18 nostr.huszonegy.world wons.calva.dev 47.4979 37.3986 19.0402 -121.964
19 wot.sebastix.social pyramid-relay.meows.lol 51.8933 38.7475 4.42083 -77.5317
20 articles.layer3.news relay.pinseekr.golf 37.3387 43.6532 -121.885 -79.3832
21 nostr.spicyz.io nostr-relay.online 40.7357 43.6532 -74.1724 -79.3832
22 nostr.jerrynya.fun relay.evanverma.com 31.2304 40.8302 121.474 -74.1299
23 nostr.oxtr.dev relay.nostr.wirednet.jp 50.4754 34.706 12.3683 135.493
24 relay.mwaters.net nostr.spicyz.io 50.9871 43.6532 2.12554 -79.3832
25 vitor.nostr1.com ynostr.yael.at 40.7128 60.1699 -74.006 24.9384
26 relay.lumina.rocks testrelay.era21.space 49.0291 43.6532 8.35695 -79.3832
27 nostr-relay-1.trustlessenterprise.com nostr.islandarea.net 40.7357 35.4669 -74.1724 -97.6473
28 nostr.bilthon.dev purpura.cloud 25.8128 43.6532 -80.2377 -79.3832
29 nostr.now testnet-relay.samt.st 36.55 40.8302 139.733 -74.1299
30 nostr.girino.org relay.sigit.io 40.7357 50.4754 -74.1724 12.3683
31 nostr-01.yakihonne.com nostr.jerrynya.fun 1.32123 31.2304 103.695 121.474
32 ribo.af.nostria.app relay.bitcoindistrict.org -26.2041 43.6532 28.0473 -79.3832
33 nostrelay.memory-art.xyz nostr.ps1829.com 43.6532 33.8851 -79.3832 130.883
34 relay.evanverma.com nostr-2.21crypto.ch 40.8302 47.5356 -74.1299 8.73209
35 a.nos.lol wheat.happytavern.co 50.4754 43.6532 12.3683 -79.3832
36 purpura.cloud plebchain.club 40.7357 43.6532 -74.1724 -79.3832
37 relay.snort.social relay2.ngengine.org 43.6532 -79.3832
38 relay.holzeis.me relay01.lnfi.network 43.6532 39.0997 -79.3832 -94.5786
39 nostr.spaceshell.xyz chat-relay.zap-work.com 40.7128 43.6532 -74.006 -79.3832
40 nostr.liberty.fans relay.21e6.cz 36.9104 50.7383 -89.5875 15.0648
41 relay.fundstr.me relay.erybody.com 42.3601 41.4513 -71.0589 -81.7021
42 wot.basspistol.org nostr.commonshub.brussels 49.4521 49.4543 11.0767 11.0746
43 ribo.eu.nostria.app nostr.thebiglake.org 52.3676 32.71 4.90414 -96.6745
44 relay.notoshi.win nostr-pub.wellorder.net 13.4166 45.5201 101.335 -122.99
45 relay.stream.labs.h3.se relay2.angor.io 59.4016 48.1046 17.9455 11.6002
46 nostr.stakey.net relay.ru.ac.th 52.3676 13.7607 4.90414 100.627
47 relay.satlantis.io relay.threenine.services 32.8769 51.5524 -80.0114 -0.29686
48 nostr-2.21crypto.ch nostr-relay-1.trustlessenterprise.com 47.4988 43.6532 8.72369 -79.3832
49 nostr.satstralia.com nostr.zoracle.org 64.1476 45.6018 -21.9392 -121.185
50 slick.mjex.me nostr-dev.wellorder.net 39.048 45.5201 -77.4817 -122.99
51 nostr-relay.nextblockvending.com relay.minibolt.info 47.2343 43.6532 -119.853 -79.3832
52 relay.origin.land nostr-relay.nextblockvending.com 35.6673 47.2343 139.751 -119.853
53 nostr.fbxl.net relay.satlantis.io 48.382 32.8769 -89.2502 -80.0114
54 relay.nostr.place relay.malxte.de 32.7767 52.52 -96.797 13.405
55 nr.yay.so nostr.nodesmap.com 46.2126 59.3327 6.1154 18.0656
56 nostream.breadslice.com relay.mccormick.cx 40.7357 52.3563 -74.1724 4.95714
57 wot.tealeaf.dev nostr-relay.xbytez.io 33.7488 50.6924 -84.3877 3.20113
58 relay.primal.net nostr-relay.zimage.com 43.6532 34.0549 -79.3832 -118.243
59 relay.chorus.community schnorr.me 50.1109 43.6532 8.68213 -79.3832
60 wot.dergigi.com relay.nostr.place 64.1476 32.7767 -21.9392 -96.797
61 nostr-relay.amethyst.name shu05.shugur.net 39.0438 48.8566 -77.4874 2.35222
62 nostr.mehdibekhtaoui.com nostrja-kari.heguro.com 49.4939 43.6532 -1.54813 -79.3832
63 relay.mess.ch relay.getsafebox.app 46.948 43.6532 7.44745 -79.3832
64 relay.sigit.io nostr.tac.lol 50.4754 47.4748 12.3683 -122.273
65 relay-rpi.edufeed.org relay.cosmicbolt.net 49.4543 37.3986 11.0746 -121.964
66 nostr.faultables.net nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
67 relay.getsafebox.app nostr-relayrs.gateway.in.th 43.6532 15.2634 -79.3832 100.344
68 cyberspace.nostr1.com relay.nostrverse.net 40.7128 43.6532 -74.006 -79.3832
69 relay.endfiat.money wot.dergigi.com 43.6532 64.1476 -79.3832 -21.9392
70 soloco.nl fanfares.nostr1.com 43.6532 40.7057 -79.3832 -74.0136
71 nostr.kungfu-g.rip relay.bitcoinartclock.com 33.7946 50.4754 -84.4488 12.3683
72 nostrelay.circum.space 0m0sef4nb45q6.cloreai.ru 51.2217 43.6532 6.77616 -79.3832
73 relay.trustroots.org relay.btcforplebs.com 43.6532 -79.3832
74 relay.wellorder.net relay.zone667.com 45.5201 60.1699 -122.99 24.9384
75 relay.coinos.io nostrcheck.tnsor.network 40.7357 43.6532 -74.1724 -79.3832
76 relay-testnet.k8s.layer3.news nostr-relay.amethyst.name 37.3387 39.0438 -121.885 -77.4874
77 relay.nostriot.com relay.lightning.pub 41.5695 39.0438 -83.9786 -77.4874
78 relay.bitcoinartclock.com nostr-verified.wellorder.net 50.4754 45.5201 12.3683 -122.99
79 nostr.einundzwanzig.space relay.agorist.space 50.1109 52.3734 8.68213 4.89406
80 nostr.casa21.space nostr-relay.cbrx.io 43.6532 -79.3832
81 premium.primal.net nostr.overmind.lol 40.7357 43.6532 -74.1724 -79.3832
82 relay.tagayasu.xyz relay.jeffg.fyi 43.6715 43.6532 -79.38 -79.3832
83 nostr.mom relay.seq1.net 50.4754 43.6532 12.3683 -79.3832
84 nostr.zenon.network relay.lumina.rocks 43.5009 49.0291 -70.4428 8.35695
85 nostr-pub.wellorder.net relay.samt.st 45.5201 40.8302 -122.99 -74.1299
86 relay.g1sms.fr nostr.rtvslawenia.com 43.9432 49.4543 2.07537 11.0746
87 nostr-rs-relay-ishosta.phamthanh.me nostr.red5d.dev 40.7357 43.6532 -74.1724 -79.3832
88 relay.illuminodes.com slick.mjex.me 47.6061 39.048 -122.333 -77.4817
89 dizzyspells.nostr1.com nostr.vulpem.com 40.7057 49.4543 -74.0136 11.0746
90 relay.mostro.network relay-freeharmonypeople.space 40.8302 38.7223 -74.1299 -9.13934
91 relay.nostr.wirednet.jp relay.routstr.com 34.706 43.6532 135.493 -79.3832
92 relay.barine.co garden.zap.cooking 43.6532 40.9481 -79.3832 -79.7428
93 relay.damus.io relay.nostrops.com 43.6532 33.7584 -79.3832 -84.6375
94 relay.0xchat.com spookstr2.nostr1.com 1.35208 40.7128 103.82 -74.006
95 relay.mattybs.lol relay.arx-ccn.com 40.7357 50.4754 -74.1724 12.3683
96 no.str.cr relay.coinos.io 9.92857 43.6532 -84.0528 -79.3832
97 relay.utxo.farm shu02.shugur.net 35.6916 21.4902 139.768 39.2246
98 nostr.pleb.one nostr.night7.space 38.6327 50.4754 -90.1961 12.3683
99 relay-dev.satlantis.io nostr.casa21.space 40.8302 43.6532 -74.1299 -79.3832
100 relay.nostrdice.com nostr.bgbitcoin.club -33.8688 50.4754 151.209 12.3683
101 relay.nostraddress.com strfry.felixzieger.de 40.7357 50.1013 -74.1724 8.62643
102 satsage.xyz wot.brightbolt.net 37.3986 47.6735 -121.964 -116.781
103 offchain.pub freelay.sovbit.host 36.1809 64.1476 -115.241 -21.9392
104 noxir.kpherox.dev relay-dev.satlantis.io 34.8587 40.8302 135.509 -74.1299
105 nostr-relay.psfoundation.info relay.vantis.ninja 39.0438 43.6532 -77.4874 -79.3832
106 khatru.nostrver.se relay.cyphernomad.com 51.8933 60.1699 4.42083 24.9384
107 purplerelay.com relay.thebluepulse.com 50.1109 49.4521 8.68213 11.0767
108 relay.tapestry.ninja fenrir-s.notoshi.win 40.8054 43.6532 -74.0241 -79.3832
109 nostr.night7.space nostr.rblb.it 50.4754 43.7094 12.3683 10.6582
110 nostr.rikmeijer.nl relay.goodmorningbitcoin.com 50.4754 43.6532 12.3683 -79.3832
111 relay1.nostrchat.io wot.sebastix.social 60.1699 51.1792 24.9384 5.89444
112 nostr.21crypto.ch wotr.relatr.xyz 47.4988 53.3498 8.72369 -6.26031
113 wot.soundhsa.com nostr.mehdibekhtaoui.com 33.1384 49.4939 -95.6011 -1.54813
114 relay.orangepill.ovh relay.snort.social 49.1689 53.3498 -0.358841 -6.26031
115 talon.quest librerelay.aaroniumii.com 43.6532 -79.3832
116 nostr-rs-relay.dev.fedibtc.com vault.iris.to 39.0438 43.6532 -77.4874 -79.3832
117 wot.codingarena.top relay.bullishbounty.com 50.4754 43.6532 12.3683 -79.3832
118 fanfares.nostr1.com nostr.myshosholoza.co.za 40.7128 52.3676 -74.006 4.90414
119 gnostr.com adre.su 42.6978 59.8845 23.3246 30.3184
120 nostrcheck.tnsor.network nostr.quali.chat 40.7357 60.1699 -74.1724 24.9384
121 nostrelites.org dev-relay.lnfi.network 41.8781 39.0997 -87.6298 -94.5786
122 relay.bitcoindistrict.org nostr-03.dorafactory.org 43.6532 1.35208 -79.3832 103.82
123 relay.fr13nd5.com relay.nostriot.com 52.5233 41.5695 13.3426 -83.9786
124 wot.nostr.place ragnar.nostrops.com 30.2672 33.7946 -97.7431 -84.4488
125 ithurtswhenip.ee kitchen.zap.cooking 51.223 43.6532 6.78245 -79.3832
126 relay.dwadziesciajeden.pl nos4smartnkind.tech 52.2297 40.1872 21.0122 44.5152
127 relay2.ngengine.org nostr-relay.psfoundation.info 40.7357 39.0438 -74.1724 -77.4874
128 relay.nostr.net relay.origin.land 50.4754 35.6673 12.3683 139.751
129 nostr-relay.cbrx.io new.orly.dev 40.7357 55.9349 -74.1724 23.3137
130 dev-relay.lnfi.network strfry.ymir.cloud 39.0997 34.0965 -94.5786 -117.585
131 relay.jeffg.fyi ribo.eu.nostria.app 43.6532 52.3676 -79.3832 4.90414
132 relay.ngengine.org nostr.bilthon.dev 40.7357 25.7975 -74.1724 -80.23
133 nos.xmark.cc bcast.girino.org 50.6924 43.6532 3.20113 -79.3832
134 relay.21e6.cz yabu.me 50.1682 35.6092 14.0546 139.73
135 relay.degmods.com nostr.blankfors.se 50.4754 60.1699 12.3683 24.9384
136 nostr.coincrowd.fund relay.guggero.org 39.0438 47.3769 -77.4874 8.54169
137 nostr.myshosholoza.co.za shu03.shugur.net 52.3676 25.2048 4.90414 55.2708
138 relay.digitalezukunft.cyou temp.iris.to 45.5019 43.6532 -73.5674 -79.3832
139 r.lostr.net r.alphaama.com 52.3676 60.1699 4.90414 24.9384
140 relay.etch.social nostr.mom 41.2619 50.4754 -95.8608 12.3683
141 nostr.tac.lol nostr.oxtr.dev 47.4748 50.4754 -122.273 12.3683
142 nostr.azzamo.net nostr.faultables.net 52.2633 43.6532 21.0283 -79.3832
143 nostr.4rs.nl relay.internationalright-wing.org 49.0291 -22.5022 8.35696 -48.7114
144 nostr-03.dorafactory.org nostr.bitczat.pl 1.35208 60.1699 103.82 24.9384
145 relay.copylaradio.com relay.endfiat.money 51.223 43.6532 6.78245 -79.3832
146 nostr.camalolo.com nostr.luisschwab.net 24.1469 43.6532 120.684 -79.3832
147 nostr-dev.wellorder.net nostr-relay.gateway.in.th 45.5201 15.2634 -122.99 100.344
148 relay.nostx.io nostr-02.yakihonne.com 43.6532 1.32123 -79.3832 103.695
149 r.bitcoinhold.net nostr.sathoarder.com 43.6532 48.5734 -79.3832 7.75211
150 nproxy.kristapsk.lv strfry.shock.network 60.1699 39.0438 24.9384 -77.4874
151 adre.su wot.nostr.place 59.9311 32.7767 30.3609 -96.797
152 relay.hasenpfeffr.com nostr.azzamo.net 39.0438 52.2633 -77.4874 21.0283
153 nos.lol nostr.hekster.org 50.4754 37.3986 12.3683 -121.964
154 relay.nostr.band czas.xyz 60.1699 48.8566 24.9384 2.35222
155 nostr-02.czas.top premium.primal.net 53.471 43.6532 9.88208 -79.3832
156 relay.nosto.re pyramid.aaro.cc 51.8933 32.7767 4.42083 -96.797
157 nostr.plantroon.com purplerelay.com 50.1013 50.1109 8.62643 8.68213
158 nostr.rblb.it nostr.stakey.net 43.4633 52.3676 11.8796 4.90414
159 nostr.thebiglake.org nostr.girino.org 32.71 43.6532 -96.6745 -79.3832
160 nostr.luisschwab.net relay.agora.social 40.7357 50.7383 -74.1724 15.0648
161 relay.electriclifestyle.com nostr-rs-relay.dev.fedibtc.com 26.2897 39.0438 -80.1293 -77.4874
162 librerelay.aaroniumii.com relay.nostrzh.org 43.6532 -79.3832
163 nostr.88mph.life nostr.n7ekb.net 40.7357 47.4941 -74.1724 -122.294
164 shu02.shugur.net relay.fountain.fm 21.4902 43.6532 39.2246 -79.3832
165 relay.hook.cafe relay-fra.zombi.cloudrodion.com 40.7357 48.8566 -74.1724 2.35222
166 strfry.elswa-dev.online nostr.mifen.me 48.8566 43.6532 2.35222 -79.3832
167 wot.sudocarlos.com relay.anmore.me 51.5072 49.704 -0.127586 -124.918
168 relay.islandbitcoin.com kotukonostr.onrender.com 12.8498 37.7775 77.6545 -122.397
169 nostr.tadryanom.me relay.0xchat.com 40.7357 1.35208 -74.1724 103.82
170 relay.zone667.com bitsat.molonlabe.holdings 60.1699 51.4012 24.9384 -1.3147
171 nostr.agentcampfire.com relay.layer.systems 50.8933 49.0291 6.05805 8.35695
172 relay.ditto.pub api.freefrom.space/v1/ws 40.7357 43.6532 -74.1724 -79.3832
173 relay03.lnfi.network relay.nostrdice.com 39.0997 -33.8688 -94.5786 151.209
174 relay2.angor.io relay.nostrhub.fr 48.1046 48.1045 11.6002 11.6004
175 srtrelay.c-stellar.net bbw-nostr.xyz 40.7357 41.5284 -74.1724 -87.4237
176 relayone.soundhsa.com relay.npubhaus.com 33.1384 43.6532 -95.6011 -79.3832
177 relay.javi.space relay.artx.market 43.4633 43.6532 11.8796 -79.3832
178 nostr.carroarmato0.be nostr.snowbla.de 50.9928 60.1699 3.26317 24.9384
179 nostr.hekster.org relay.bnos.space 37.3986 43.6532 -121.964 -79.3832
180 strfry.shock.network wot.nostr.party 41.8959 36.1512 -88.2169 -86.7835
181 nostr.2b9t.xyz relayrs.notoshi.win 34.0549 43.6532 -118.243 -79.3832
182 relay.toastr.net strfry.openhoofd.nl 40.8054 51.9229 -74.0241 4.40833
183 relay.bitcoinveneto.org nostr.notribe.net 64.1466 40.8302 -21.9426 -74.1299
184 relay.wavlake.com relay.libernet.app 41.2619 43.6532 -95.8608 -79.3832
185 relay.arx-ccn.com relay.chakany.systems 50.4754 43.6532 12.3683 -79.3832
186 relay.cosmicbolt.net nostr.thaliyal.com 37.3986 40.8218 -121.964 -74.45
187 relay.mccormick.cx relay.ryzizub.com 52.3563 43.6532 4.95714 -79.3832
188 temp.iris.to relay.nostx.io 40.7357 43.6532 -74.1724 -79.3832
189 relay.vrtmrz.net bcast.seutoba.com.br 40.7357 43.6532 -74.1724 -79.3832
190 nostr-relay.zimage.com nostrelay.circum.space 34.282 52.3676 -118.439 4.90414
191 nostr.data.haus relay.janx.com 50.4754 41.2054 12.3683 -76.0049
192 ribo.us.nostria.app relay.fundstr.me 41.5868 42.3601 -93.625 -71.0589
193 nostr.vulpem.com relay.toastr.net 49.4543 40.8054 11.0746 -74.0241
194 relay.agora.social relay.puresignal.news 50.7383 43.6532 15.0648 -79.3832
195 nostr.ovia.to relay.magiccity.live 43.6532 25.7975 -79.3832 -80.23
196 nostr.red5d.dev satsage.xyz 40.7357 37.3986 -74.1724 -121.964
197 orangesync.tech nostr.now 50.1109 36.55 8.68213 139.733
198 relay.fountain.fm offchain.pub 39.0997 47.6743 -94.5786 -117.112
199 relay.aloftus.io relay-rpi.edufeed.org 34.0881 49.4521 -118.379 11.0767
200 nostr.hifish.org nostr.chaima.info 47.4043 50.1109 8.57398 8.68213
201 relay.siamdev.cc freeben666.fr 13.9178 43.7221 100.424 7.15296
202 fenrir-s.notoshi.win relay.siamdev.cc 43.6532 13.8434 -79.3832 100.363
203 nostr.overmind.lol wot.nostr.net 43.6532 -79.3832
204 wheat.happytavern.co relay.ditto.pub 40.7357 43.6532 -74.1724 -79.3832
205 nostr.rtvslawenia.com relay.ngengine.org 49.4543 43.6532 11.0746 -79.3832
206 relay.nostrhub.fr relay.tagayasu.xyz 48.1046 45.4215 11.6002 -75.6972
207 strfry.openhoofd.nl wot.yesnostr.net 51.9229 50.9871 4.40833 2.12554
208 relay.usefusion.ai relay.wavefunc.live 38.7134 34.0362 -78.1591 -118.443
209 relay.credenso.cafe relay.snotr.nl:49999 43.3601 52.0067 -80.3127 4.35556
210 nostr.lostr.space nostr.dler.com 40.7357 25.0501 -74.1724 121.565
211 relay.jmoose.rocks relay.nostr.net 60.1699 43.6532 24.9384 -79.3832
212 relay.nostromo.social relay.comcomponent.com 49.4543 34.7062 11.0746 135.493
213 nostr.jfischer.org relay.nosto.re 49.0291 51.1792 8.35696 5.89444
214 relay.wolfcoil.com nostr2.girino.org 35.6092 43.6532 139.73 -79.3832
215 nostr.thaliyal.com wot.sudocarlos.com 40.8218 43.6532 -74.45 -79.3832
216 relay.magiccity.live nostr.robosats.org 25.8128 64.1476 -80.2377 -21.9392
217 relay.puresignal.news strfry.elswa-dev.online 40.7357 50.1109 -74.1724 8.68213
218 prl.plus nos.lol 55.7623 50.4754 37.6381 12.3683
219 wot.brightbolt.net aaa-api.freefrom.space/v1/ws 47.6735 43.6532 -116.781 -79.3832
220 relay.varke.eu relay.mitchelltribe.com 52.6921 39.0438 6.19372 -77.4874
221 alienos.libretechsystems.xyz prl.plus 55.4724 42.6978 9.87335 23.3246
222 relay.goodmorningbitcoin.com relay.illuminodes.com 43.6532 47.6061 -79.3832 -122.333
223 pyramid.fiatjaf.com srtrelay.c-stellar.net 51.5072 43.6532 -0.127586 -79.3832
224 relay02.lnfi.network nostrelites.org 39.0997 41.8781 -94.5786 -87.6298
225 nostr.davidebtc.me nostr.na.social 50.1109 43.6532 8.68213 -79.3832
226 nostr-verified.wellorder.net nostr.tadryanom.me 45.5201 43.6532 -122.99 -79.3832
227 relay.cypherflow.ai okn.czas.plus 48.8566 50.1109 2.35222 8.68213
228 nostr.snowbla.de relay03.lnfi.network 60.1699 39.0997 24.9384 -94.5786
229 inbox.azzamo.net cyberspace.nostr1.com 52.2633 40.7128 21.0283 -74.006
230 shu01.shugur.net notemine.io 21.4902 52.2026 39.2246 20.9397
231 nostr.middling.mydns.jp wot.sovbit.host 35.8099 64.1466 140.12 -21.9426
232 nostr.kalf.org santo.iguanatech.net 52.3676 40.8302 4.90414 -74.1299
233 relay.laantungir.net relay.degmods.com -19.4692 50.4754 -42.5315 12.3683
234 relay.angor.io relay.chorus.community 48.1046 50.1109 11.6002 8.68213
235 nostr2.girino.org relay.orangepill.ovh 40.7357 49.1689 -74.1724 -0.358841
236 relay01.lnfi.network wot.dtonon.com 39.0997 43.6532 -94.5786 -79.3832
237 nostr.chaima.info nostr.parallel.hetu.org:8443 51.223 1.35208 6.78245 103.82
238 x.kojira.io discovery.eu.nostria.app 43.6532 52.3676 -79.3832 4.90414
239 shu04.shugur.net nostrcheck.me 25.2604 43.6532 55.2989 -79.3832
240 santo.iguanatech.net relay-arg.zombi.cloudrodion.com 40.8302 1.35208 -74.1299 103.82
241 relay.artx.market espelho.girino.org 43.652 43.6532 -79.3633 -79.3832
242 alien.macneilmediagroup.com nostr.superfriends.online 40.7357 43.6532 -74.1724 -79.3832
243 nostr.sathoarder.com relay.keykeeper.world 48.5734 40.7824 7.75211 -74.0711
244 zap.watch nostr.ovia.to 45.5029 43.6532 -73.5723 -79.3832
245 relay.basspistol.org x.kojira.io 46.2044 43.6532 6.14316 -79.3832
246 relay.13room.space bucket.coracle.social 43.6532 37.7775 -79.3832 -122.397
247 relay.bullishbounty.com nostr.data.haus 40.7357 50.4754 -74.1724 12.3683
248 theoutpost.life nostr-relay.corb.net 64.1476 38.8353 -21.9392 -104.822
249 nostr.coincards.com relay.credenso.cafe 53.5501 43.3601 -113.469 -80.3127
250 black.nostrcity.club relayone.soundhsa.com 41.8781 33.1384 -87.6298 -95.6011
251 relay.npubhaus.com relay.notoshi.win 40.7357 14.2046 -74.1724 101.213
252 relay.freeplace.nl relay.olas.app 52.3676 50.4754 4.90414 12.3683
253 relay.seq1.net nostr.mikoshi.de 43.6532 47.8786 -79.3832 11.911
254 ynostr.yael.at relay.mostro.network 60.1699 40.8302 24.9384 -74.1299
255 relay.nostr.vet nostr.noones.com 52.6467 50.1109 4.7395 8.68213
256 relay.lifpay.me nostr.4rs.nl 1.35208 49.0291 103.82 8.35696
257 relay.chakany.systems strfry.bonsai.com 43.6532 37.8715 -79.3832 -122.273
258 relay.lightning.pub nostr.spaceshell.xyz 41.8959 43.6532 -88.2169 -79.3832
259 wot.dtonon.com relay.contextvm.org 43.6532 53.3498 -79.3832 -6.26031
260 yabu.me relay.westernbtc.com 35.6092 44.5401 139.73 -123.368
261 wot.nostr.net soloco.nl 43.6532 -79.3832
262 relay.libernet.app relay.nostar.org 40.7357 43.6532 -74.1724 -79.3832
263 relay04.lnfi.network nostr.lkjsxc.com 39.0997 43.6532 -94.5786 -79.3832
264 nostr.0x7e.xyz theoutpost.life 47.4988 64.1476 8.72369 -21.9392
265 nostr.mikoshi.de relay.javi.space 50.1109 43.4633 8.68213 11.8796
266 wot.nostr.party khatru.nostrver.se 36.1627 51.1792 -86.7816 5.89444
267 relay.letsfo.com relay.nostrcheck.me 51.098 43.6532 17.0321 -79.3832
268 nostr.makibisskey.work nostr.bond 43.6532 50.1109 -79.3832 8.68213
269 nostr.simplex.icu relay.cypherflow.ai 50.8198 48.8566 -1.08798 2.35222
270 relay.wellorder.net 45.5201 -122.99
271 wot.shaving.kiwi 43.6532 -79.3832