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)
}
+25 -2
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,6 +40,13 @@ 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
}
@@ -45,6 +56,8 @@ struct RequestSyncPacket {
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
+5 -1
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,6 +205,7 @@ 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 {
@@ -329,6 +331,7 @@ 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 {
@@ -410,7 +413,8 @@ struct BinaryProtocol {
signature: signature,
ttl: ttl,
version: version,
route: route
route: route,
isRSR: isRSR
)
}
}
+392 -140
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
@@ -334,6 +329,31 @@ final class BLEService: NSObject {
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.
deinit {
@@ -354,8 +374,6 @@ final class BLEService: NSObject {
}
collectionsQueue.sync(flags: .barrier) {
recentAnnounceBySender.removeAll()
recentAnnounceOrder.removeAll()
pendingPeripheralWrites.removeAll()
pendingFragmentTransfers.removeAll()
pendingNotifications.removeAll()
@@ -515,19 +533,25 @@ final class BLEService: NSObject {
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)
}
}
@@ -551,8 +575,9 @@ final class BLEService: NSObject {
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)
}
}
@@ -1773,14 +1845,21 @@ extension BLEService: CBCentralManagerDelegate {
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)
@@ -2107,12 +2184,45 @@ extension BLEService: CBPeripheralDelegate {
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 {
let prefix = frame.prefix(16).map { String(format: "%02x", $0) }.joined(separator: " ")
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
@@ -2199,7 +2311,8 @@ 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()
@@ -2218,6 +2331,46 @@ extension BLEService: CBPeripheralManagerDelegate {
// 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,29 +2548,39 @@ 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,21 +3073,64 @@ 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)
@@ -3033,7 +3261,7 @@ 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)
@@ -3044,6 +3272,9 @@ extension BLEService {
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)
}
}
@@ -4121,6 +4368,11 @@ extension BLEService {
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,23 +59,12 @@ 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
func process(_ command: String) -> CommandResult {
@@ -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()) {
@@ -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,6 +59,9 @@ 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]
@@ -71,6 +78,11 @@ final class MeshTopologyTracker {
// 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 }
@@ -82,6 +94,11 @@ final class MeshTopologyTracker {
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)
+69 -29
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,14 +109,22 @@ 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)
}
}
@@ -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)
}
}
}
}
@@ -617,6 +617,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
private func handleSessionEstablished(peerID: PeerID, remoteStaticKey: Curve25519.KeyAgreement.PublicKey) {
+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
+13 -1
View File
@@ -16,9 +16,21 @@ struct PaymentChipView: View {
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)
}
}
}
}
+135 -12
View File
@@ -65,6 +65,22 @@ struct ChatViewModelPrivateChatExtensionTests {
// 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 {
let (viewModel, _) = makeTestableViewModel()
@@ -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))
viewModel.switchLocationChannel(to: .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()
_ = 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)
viewModel.publicMessagePipeline.flushIfNeeded()
// 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...
#expect(!viewModel.messages.contains { $0.content == "Self echo" })
}
// 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`.
@Test @MainActor
func handleNostrEvent_skipsBlockedSender() async {
let (viewModel, _) = makeTestableViewModel()
let geohash = "u4pruydq"
let blockedPubkey = "0000000000000000000000000000000000000000000000000000000000000001"
// We need to ensure `deduplicationService` doesn't block it (new instance, so empty).
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
@@ -94,6 +94,22 @@ final class TestHelpers {
}
}
@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,
operation: @escaping () async throws -> T
+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")
}
+266 -264
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
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
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
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.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.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
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
2 relay.angor.io 48.1046 11.6002
3 nostr2.girino.org black.nostrcity.club 40.7357 48.8566 -74.1724 2.35222
4 relay01.lnfi.network relay.damus.io 39.0997 43.6532 -94.5786 -79.3832
5 nostr.chaima.info relay.satmaxt.xyz 51.223 43.6532 6.78245 -79.3832
6 x.kojira.io articles.layer3.news 43.6532 37.3387 -79.3832 -121.885
7 shu04.shugur.net relay.nuts.cash 25.2604 34.0362 55.2989 -118.443
8 santo.iguanatech.net lightning.red 40.8302 53.3498 -74.1299 -6.26031
9 relay.artx.market relay.davidebtc.me 43.652 43.6532 -79.3633 -79.3832
10 alien.macneilmediagroup.com nostr-02.czas.top 40.7357 51.2277 -74.1724 6.77346
11 nostr.sathoarder.com inbox.azzamo.net 48.5734 52.2633 7.75211 21.0283
12 zap.watch relay.primal.net 45.5029 43.6532 -73.5723 -79.3832
13 relay.basspistol.org relay-testnet.k8s.layer3.news 46.2044 37.3387 6.14316 -121.885
14 relay.13room.space nostr.21crypto.ch 43.6532 47.5356 -79.3832 8.73209
15 relay.bullishbounty.com r.bitcoinhold.net 40.7357 43.6532 -74.1724 -79.3832
16 theoutpost.life nostr.self-determined.de 64.1476 54.5847 -21.9392 10.0178
17 nostr.coincards.com relay.fr13nd5.com 53.5501 52.5233 -113.469 13.3426
18 black.nostrcity.club wons.calva.dev 41.8781 37.3986 -87.6298 -121.964
19 relay.npubhaus.com pyramid-relay.meows.lol 40.7357 38.7475 -74.1724 -77.5317
20 relay.freeplace.nl relay.pinseekr.golf 52.3676 43.6532 4.90414 -79.3832
21 relay.seq1.net nostr-relay.online 43.6532 -79.3832
22 relay.evanverma.com 40.8302 -74.1299
23 relay.nostr.wirednet.jp 34.706 135.493
24 nostr.spicyz.io 43.6532 -79.3832
25 ynostr.yael.at 60.1699 24.9384
26 relay.nostr.vet testrelay.era21.space 52.6467 43.6532 4.7395 -79.3832
27 relay.lifpay.me nostr.islandarea.net 1.35208 35.4669 103.82 -97.6473
28 relay.chakany.systems purpura.cloud 43.6532 -79.3832
29 relay.lightning.pub testnet-relay.samt.st 41.8959 40.8302 -88.2169 -74.1299
30 wot.dtonon.com relay.sigit.io 43.6532 50.4754 -79.3832 12.3683
31 nostr.jerrynya.fun 31.2304 121.474
32 relay.bitcoindistrict.org 43.6532 -79.3832
33 nostr.ps1829.com 33.8851 130.883
34 nostr-2.21crypto.ch 47.5356 8.73209
35 wheat.happytavern.co 43.6532 -79.3832
36 plebchain.club 43.6532 -79.3832
37 relay2.ngengine.org 43.6532 -79.3832
38 relay01.lnfi.network 39.0997 -94.5786
39 chat-relay.zap-work.com 43.6532 -79.3832
40 relay.21e6.cz 50.7383 15.0648
41 relay.erybody.com 41.4513 -81.7021
42 nostr.commonshub.brussels 49.4543 11.0746
43 nostr.thebiglake.org 32.71 -96.6745
44 nostr-pub.wellorder.net 45.5201 -122.99
45 relay2.angor.io 48.1046 11.6002
46 relay.ru.ac.th 13.7607 100.627
47 relay.threenine.services 51.5524 -0.29686
48 nostr-relay-1.trustlessenterprise.com 43.6532 -79.3832
49 nostr.zoracle.org 45.6018 -121.185
50 nostr-dev.wellorder.net 45.5201 -122.99
51 relay.minibolt.info 43.6532 -79.3832
52 nostr-relay.nextblockvending.com 47.2343 -119.853
53 relay.satlantis.io 32.8769 -80.0114
54 relay.malxte.de 52.52 13.405
55 nostr.nodesmap.com 59.3327 18.0656
56 relay.mccormick.cx 52.3563 4.95714
57 nostr-relay.xbytez.io 50.6924 3.20113
58 nostr-relay.zimage.com 34.0549 -118.243
59 schnorr.me 43.6532 -79.3832
60 relay.nostr.place 32.7767 -96.797
61 shu05.shugur.net 48.8566 2.35222
62 nostrja-kari.heguro.com 43.6532 -79.3832
63 relay.getsafebox.app 43.6532 -79.3832
64 nostr.tac.lol 47.4748 -122.273
65 relay.cosmicbolt.net 37.3986 -121.964
66 nostr-rs-relay-ishosta.phamthanh.me 43.6532 -79.3832
67 nostr-relayrs.gateway.in.th 15.2634 100.344
68 relay.nostrverse.net 43.6532 -79.3832
69 wot.dergigi.com 64.1476 -21.9392
70 fanfares.nostr1.com 40.7057 -74.0136
71 relay.bitcoinartclock.com 50.4754 12.3683
72 0m0sef4nb45q6.cloreai.ru 43.6532 -79.3832
73 relay.btcforplebs.com 43.6532 -79.3832
74 relay.zone667.com 60.1699 24.9384
75 nostrcheck.tnsor.network 43.6532 -79.3832
76 nostr-relay.amethyst.name 39.0438 -77.4874
77 relay.lightning.pub 39.0438 -77.4874
78 nostr-verified.wellorder.net 45.5201 -122.99
79 relay.agorist.space 52.3734 4.89406
80 nostr-relay.cbrx.io 43.6532 -79.3832
81 nostr.overmind.lol 43.6532 -79.3832
82 relay.jeffg.fyi 43.6532 -79.3832
83 relay.seq1.net 43.6532 -79.3832
84 relay.lumina.rocks 49.0291 8.35695
85 relay.samt.st 40.8302 -74.1299
86 nostr.rtvslawenia.com 49.4543 11.0746
87 nostr.red5d.dev 43.6532 -79.3832
88 slick.mjex.me 39.048 -77.4817
89 nostr.vulpem.com 49.4543 11.0746
90 relay-freeharmonypeople.space 38.7223 -9.13934
91 relay.routstr.com 43.6532 -79.3832
92 garden.zap.cooking 40.9481 -79.7428
93 relay.nostrops.com 33.7584 -84.6375
94 spookstr2.nostr1.com 40.7128 -74.006
95 relay.arx-ccn.com 50.4754 12.3683
96 relay.coinos.io 43.6532 -79.3832
97 shu02.shugur.net 21.4902 39.2246
98 nostr.night7.space 50.4754 12.3683
99 nostr.casa21.space 43.6532 -79.3832
100 nostr.bgbitcoin.club 50.4754 12.3683
101 strfry.felixzieger.de 50.1013 8.62643
102 wot.brightbolt.net 47.6735 -116.781
103 freelay.sovbit.host 64.1476 -21.9392
104 relay-dev.satlantis.io 40.8302 -74.1299
105 relay.vantis.ninja 43.6532 -79.3832
106 relay.cyphernomad.com 60.1699 24.9384
107 relay.thebluepulse.com 49.4521 11.0767
108 fenrir-s.notoshi.win 43.6532 -79.3832
109 nostr.rblb.it 43.7094 10.6582
110 relay.goodmorningbitcoin.com 43.6532 -79.3832
111 wot.sebastix.social 51.1792 5.89444
112 wotr.relatr.xyz 53.3498 -6.26031
113 nostr.mehdibekhtaoui.com 49.4939 -1.54813
114 relay.snort.social 53.3498 -6.26031
115 librerelay.aaroniumii.com 43.6532 -79.3832
116 vault.iris.to 43.6532 -79.3832
117 relay.bullishbounty.com 43.6532 -79.3832
118 nostr.myshosholoza.co.za 52.3676 4.90414
119 adre.su 59.8845 30.3184
120 nostr.quali.chat 60.1699 24.9384
121 dev-relay.lnfi.network 39.0997 -94.5786
122 nostr-03.dorafactory.org 1.35208 103.82
123 relay.nostriot.com 41.5695 -83.9786
124 ragnar.nostrops.com 33.7946 -84.4488
125 kitchen.zap.cooking 43.6532 -79.3832
126 nos4smartnkind.tech 40.1872 44.5152
127 nostr-relay.psfoundation.info 39.0438 -77.4874
128 relay.origin.land 35.6673 139.751
129 new.orly.dev 55.9349 23.3137
130 strfry.ymir.cloud 34.0965 -117.585
131 ribo.eu.nostria.app 52.3676 4.90414
132 nostr.bilthon.dev 25.7975 -80.23
133 bcast.girino.org 43.6532 -79.3832
134 yabu.me 35.6092 139.73
135 nostr.blankfors.se 60.1699 24.9384
136 relay.guggero.org 47.3769 8.54169
137 shu03.shugur.net 25.2048 55.2708
138 temp.iris.to 43.6532 -79.3832
139 r.alphaama.com 60.1699 24.9384
140 nostr.mom 50.4754 12.3683
141 nostr.oxtr.dev 50.4754 12.3683
142 nostr.faultables.net 43.6532 -79.3832
143 relay.internationalright-wing.org -22.5022 -48.7114
144 nostr.bitczat.pl 60.1699 24.9384
145 relay.endfiat.money 43.6532 -79.3832
146 nostr.luisschwab.net 43.6532 -79.3832
147 nostr-relay.gateway.in.th 15.2634 100.344
148 nostr-02.yakihonne.com 1.32123 103.695
149 nostr.sathoarder.com 48.5734 7.75211
150 strfry.shock.network 39.0438 -77.4874
151 wot.nostr.place 32.7767 -96.797
152 nostr.azzamo.net 52.2633 21.0283
153 nostr.hekster.org 37.3986 -121.964
154 czas.xyz 48.8566 2.35222
155 premium.primal.net 43.6532 -79.3832
156 pyramid.aaro.cc 32.7767 -96.797
157 purplerelay.com 50.1109 8.68213
158 nostr.stakey.net 52.3676 4.90414
159 nostr.girino.org 43.6532 -79.3832
160 relay.agora.social 50.7383 15.0648
161 nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
162 relay.nostrzh.org 43.6532 -79.3832
163 nostr.n7ekb.net 47.4941 -122.294
164 relay.fountain.fm 43.6532 -79.3832
165 relay-fra.zombi.cloudrodion.com 48.8566 2.35222
166 nostr.mifen.me 43.6532 -79.3832
167 relay.anmore.me 49.704 -124.918
168 kotukonostr.onrender.com 37.7775 -122.397
169 relay.0xchat.com 1.35208 103.82
170 bitsat.molonlabe.holdings 51.4012 -1.3147
171 relay.layer.systems 49.0291 8.35695
172 api.freefrom.space/v1/ws 43.6532 -79.3832
173 relay.nostrdice.com -33.8688 151.209
174 relay.nostrhub.fr 48.1045 11.6004
175 bbw-nostr.xyz 41.5284 -87.4237
176 relay.npubhaus.com 43.6532 -79.3832
177 relay.artx.market 43.6532 -79.3832
178 nostr.snowbla.de 60.1699 24.9384
179 relay.bnos.space 43.6532 -79.3832
180 wot.nostr.party 36.1512 -86.7835
181 relayrs.notoshi.win 43.6532 -79.3832
182 strfry.openhoofd.nl 51.9229 4.40833
183 nostr.notribe.net 40.8302 -74.1299
184 relay.libernet.app 43.6532 -79.3832
185 relay.chakany.systems 43.6532 -79.3832
186 nostr.thaliyal.com 40.8218 -74.45
187 relay.ryzizub.com 43.6532 -79.3832
188 relay.nostx.io 43.6532 -79.3832
189 bcast.seutoba.com.br 43.6532 -79.3832
190 nostrelay.circum.space 52.3676 4.90414
191 relay.janx.com 41.2054 -76.0049
192 relay.fundstr.me 42.3601 -71.0589
193 relay.toastr.net 40.8054 -74.0241
194 relay.puresignal.news 43.6532 -79.3832
195 relay.magiccity.live 25.7975 -80.23
196 satsage.xyz 37.3986 -121.964
197 nostr.now 36.55 139.733
198 offchain.pub 47.6743 -117.112
199 relay-rpi.edufeed.org 49.4521 11.0767
200 nostr.chaima.info 50.1109 8.68213
201 freeben666.fr 43.7221 7.15296
202 relay.siamdev.cc 13.8434 100.363
203 wot.nostr.net 43.6532 -79.3832
204 relay.libernet.app relay.ditto.pub 40.7357 43.6532 -74.1724 -79.3832
205 relay04.lnfi.network relay.ngengine.org 39.0997 43.6532 -94.5786 -79.3832
206 nostr.0x7e.xyz relay.tagayasu.xyz 47.4988 45.4215 8.72369 -75.6972
207 nostr.mikoshi.de wot.yesnostr.net 50.1109 50.9871 8.68213 2.12554
208 wot.nostr.party relay.wavefunc.live 36.1627 34.0362 -86.7816 -118.443
209 relay.letsfo.com relay.snotr.nl:49999 51.098 52.0067 17.0321 4.35556
210 nostr.makibisskey.work nostr.dler.com 43.6532 25.0501 -79.3832 121.565
211 nostr.simplex.icu relay.nostr.net 50.8198 43.6532 -1.08798 -79.3832
212 relay.comcomponent.com 34.7062 135.493
213 relay.nosto.re 51.1792 5.89444
214 nostr2.girino.org 43.6532 -79.3832
215 wot.sudocarlos.com 43.6532 -79.3832
216 nostr.robosats.org 64.1476 -21.9392
217 strfry.elswa-dev.online 50.1109 8.68213
218 nos.lol 50.4754 12.3683
219 aaa-api.freefrom.space/v1/ws 43.6532 -79.3832
220 relay.mitchelltribe.com 39.0438 -77.4874
221 prl.plus 42.6978 23.3246
222 relay.illuminodes.com 47.6061 -122.333
223 srtrelay.c-stellar.net 43.6532 -79.3832
224 nostrelites.org 41.8781 -87.6298
225 nostr.na.social 43.6532 -79.3832
226 nostr.tadryanom.me 43.6532 -79.3832
227 okn.czas.plus 50.1109 8.68213
228 relay03.lnfi.network 39.0997 -94.5786
229 cyberspace.nostr1.com 40.7128 -74.006
230 notemine.io 52.2026 20.9397
231 wot.sovbit.host 64.1466 -21.9426
232 santo.iguanatech.net 40.8302 -74.1299
233 relay.degmods.com 50.4754 12.3683
234 relay.chorus.community 50.1109 8.68213
235 relay.orangepill.ovh 49.1689 -0.358841
236 wot.dtonon.com 43.6532 -79.3832
237 nostr.parallel.hetu.org:8443 1.35208 103.82
238 discovery.eu.nostria.app 52.3676 4.90414
239 nostrcheck.me 43.6532 -79.3832
240 relay-arg.zombi.cloudrodion.com 1.35208 103.82
241 espelho.girino.org 43.6532 -79.3832
242 nostr.superfriends.online 43.6532 -79.3832
243 relay.keykeeper.world 40.7824 -74.0711
244 nostr.ovia.to 43.6532 -79.3832
245 x.kojira.io 43.6532 -79.3832
246 bucket.coracle.social 37.7775 -122.397
247 nostr.data.haus 50.4754 12.3683
248 nostr-relay.corb.net 38.8353 -104.822
249 relay.credenso.cafe 43.3601 -80.3127
250 relayone.soundhsa.com 33.1384 -95.6011
251 relay.notoshi.win 14.2046 101.213
252 relay.olas.app 50.4754 12.3683
253 nostr.mikoshi.de 47.8786 11.911
254 relay.mostro.network 40.8302 -74.1299
255 nostr.noones.com 50.1109 8.68213
256 nostr.4rs.nl 49.0291 8.35696
257 strfry.bonsai.com 37.8715 -122.273
258 nostr.spaceshell.xyz 43.6532 -79.3832
259 relay.contextvm.org 53.3498 -6.26031
260 relay.westernbtc.com 44.5401 -123.368
261 soloco.nl 43.6532 -79.3832
262 relay.nostar.org 43.6532 -79.3832
263 nostr.lkjsxc.com 43.6532 -79.3832
264 theoutpost.life 64.1476 -21.9392
265 relay.javi.space 43.4633 11.8796
266 khatru.nostrver.se 51.1792 5.89444
267 relay.nostrcheck.me 43.6532 -79.3832
268 nostr.bond 50.1109 8.68213
269 relay.cypherflow.ai 48.8566 2.35222
270 relay.wellorder.net 45.5201 -122.99
271 wot.shaving.kiwi 43.6532 -79.3832