From f59139c33c32ab522a221f475a7d8ef7ada02683 Mon Sep 17 00:00:00 2001 From: jack Date: Sun, 24 Aug 2025 11:54:03 +0200 Subject: [PATCH] feat(georelay): route geohash kind 20000 via nearest relays; add GeoRelayDirectory; target geohash subscriptions; avoid duplicate connections --- bitchat.xcodeproj/project.pbxproj | 12 + bitchat/Nostr/GeoRelayDirectory.swift | 81 +++++++ bitchat/Nostr/NostrRelayManager.swift | 29 ++- bitchat/Protocols/Geohash.swift | 32 ++- bitchat/ViewModels/ChatViewModel.swift | 35 ++- relays/online_relays_gps.csv | 293 +++++++++++++++++++++++++ 6 files changed, 471 insertions(+), 11 deletions(-) create mode 100644 bitchat/Nostr/GeoRelayDirectory.swift create mode 100644 relays/online_relays_gps.csv diff --git a/bitchat.xcodeproj/project.pbxproj b/bitchat.xcodeproj/project.pbxproj index c9b5eb8c..4aae4c91 100644 --- a/bitchat.xcodeproj/project.pbxproj +++ b/bitchat.xcodeproj/project.pbxproj @@ -64,6 +64,10 @@ 3849CA6D99B2D536636DF4A6 /* MockBLEService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7CCF2BD78A3F3DAE6DA145 /* MockBLEService.swift */; }; 38EDDC049FD56B1BB1F14C91 /* IdentityModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 05BA20BC0F123F1507C5C247 /* IdentityModels.swift */; }; 3EE336D150427F736F32B56C /* P256K in Frameworks */ = {isa = PBXBuildFile; productRef = B1D9136AA0083366353BFA2F /* P256K */; }; + E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */; }; + E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */; }; + E0A1B2C3D4E5F6012345678D /* online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* online_relays_gps.csv */; }; + E0A1B2C3D4E5F6012345678E /* online_relays_gps.csv in Resources */ = {isa = PBXBuildFile; fileRef = E0A1B2C3D4E5F6012345678A /* online_relays_gps.csv */; }; 4B747085D07A1BCE0F5BA612 /* BinaryProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2136C3E22D02D4A8DBE7EAB /* BinaryProtocol.swift */; }; 501BC56B1A08C0327A09AAF1 /* NoiseEncryptionService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 394E8A1AC76EFAE352075BE9 /* NoiseEncryptionService.swift */; }; 5C93B4FDD0C448C3EDDBF8AE /* FavoritesPersistenceService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 419BFFF209EBA93F410E9E9F /* FavoritesPersistenceService.swift */; }; @@ -221,6 +225,8 @@ 5318B743C64628A125261163 /* BinaryEncodingUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BinaryEncodingUtils.swift; sourceTree = ""; }; 5BC5AB43F4A8FB62C935CD74 /* IntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntegrationTests.swift; sourceTree = ""; }; 5F8043995007F0D84438EDD9 /* NostrIdentity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrIdentity.swift; sourceTree = ""; }; + E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GeoRelayDirectory.swift; sourceTree = ""; }; + E0A1B2C3D4E5F6012345678A /* online_relays_gps.csv */ = {isa = PBXFileReference; lastKnownFileType = text; path = relays/online_relays_gps.csv; sourceTree = ""; }; 61F92EBA29C47C0FCC482F1F /* bitchatShareExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = bitchatShareExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; 763E0DBA9492A654FC0CDCB9 /* AppInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppInfoView.swift; sourceTree = ""; }; 78595178957244CBDF7E79B6 /* NostrRelayManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrRelayManager.swift; sourceTree = ""; }; @@ -302,6 +308,7 @@ isa = PBXGroup; children = ( 2F82C5FC8433F4064F079D1F /* bitchat */, + E0A1B2C3D4E5F6012345678A /* online_relays_gps.csv */, A2E8C336FA1ADBEC03261DFD /* bitchatShareExtension */, C3D98EB3E1B455E321F519F4 /* bitchatTests */, 9F37F9F2C353B58AC809E93B /* Products */, @@ -519,6 +526,7 @@ 5F8043995007F0D84438EDD9 /* NostrIdentity.swift */, 2E5A9FF4AEA8A923317ED26A /* NostrProtocol.swift */, 78595178957244CBDF7E79B6 /* NostrRelayManager.swift */, + E0A1B2C3D4E5F60123456789 /* GeoRelayDirectory.swift */, ); path = Nostr; sourceTree = ""; @@ -683,6 +691,7 @@ buildActionMask = 2147483647; files = ( 7DD72D928FF9DD3CA81B46B0 /* Assets.xcassets in Resources */, + E0A1B2C3D4E5F6012345678D /* online_relays_gps.csv in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -692,6 +701,7 @@ files = ( BCCFEDC1EBE59323C3C470BF /* Assets.xcassets in Resources */, E65BBB6544FE0159F3C6C3A8 /* LaunchScreen.storyboard in Resources */, + E0A1B2C3D4E5F6012345678E /* online_relays_gps.csv in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -756,6 +766,7 @@ D111988977C3BC246AB27FA4 /* SecureLogger.swift in Sources */, 8DE687D2EB5EB120868DBFB5 /* BLEService.swift in Sources */, AA11BB22CC33DD44EE55FF66 /* MessageTextHelpers.swift in Sources */, + E0A1B2C3D4E5F6012345678B /* GeoRelayDirectory.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -809,6 +820,7 @@ EC5241969D2550B97629EBD0 /* SecureLogger.swift in Sources */, C165DD35BB8E9C327A3C2DA4 /* BLEService.swift in Sources */, AA11BB22CC33DD44EE55FF67 /* MessageTextHelpers.swift in Sources */, + E0A1B2C3D4E5F6012345678C /* GeoRelayDirectory.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/bitchat/Nostr/GeoRelayDirectory.swift b/bitchat/Nostr/GeoRelayDirectory.swift new file mode 100644 index 00000000..c27be66a --- /dev/null +++ b/bitchat/Nostr/GeoRelayDirectory.swift @@ -0,0 +1,81 @@ +import Foundation + +/// Directory of online Nostr relays with approximate GPS locations, used for geohash routing. +struct GeoRelayDirectory { + struct Entry: Hashable { + let host: String + let lat: Double + let lon: Double + } + + static let shared = GeoRelayDirectory() + private let entries: [Entry] + + private init() { + self.entries = GeoRelayDirectory.loadEntries() + } + + /// Returns up to `count` relay URLs (wss://) closest to the geohash center. + func closestRelays(toGeohash geohash: String, count: Int = 5) -> [String] { + let center = Geohash.decodeCenter(geohash) + return closestRelays(toLat: center.lat, lon: center.lon, count: count) + } + + /// Returns up to `count` relay URLs (wss://) closest to the given coordinate. + func closestRelays(toLat lat: Double, lon: Double, count: Int = 5) -> [String] { + guard !entries.isEmpty else { return [] } + let sorted = entries + .sorted { a, b in + haversineKm(lat, lon, a.lat, a.lon) < haversineKm(lat, lon, b.lat, b.lon) + } + .prefix(count) + return sorted.map { "wss://\($0.host)" } + } + + // MARK: - Loading + private static func loadEntries() -> [Entry] { + // Try bundled resource first + if let url = Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv") ?? + Bundle.main.url(forResource: "online_relays_gps", withExtension: "csv", subdirectory: "relays"), + let data = try? Data(contentsOf: url), + let text = String(data: data, encoding: .utf8) { + return parseCSV(text) + } + // Try filesystem path (development/test environments) + if let cwd = FileManager.default.currentDirectoryPath as String?, + let data = try? Data(contentsOf: URL(fileURLWithPath: cwd).appendingPathComponent("relays/online_relays_gps.csv")), + let text = String(data: data, encoding: .utf8) { + return parseCSV(text) + } + // No data available + SecureLogger.log("GeoRelayDirectory: relay CSV not found; falling back to default relay set", category: SecureLogger.session, level: .warning) + return [] + } + + private static func parseCSV(_ text: String) -> [Entry] { + var result: Set = [] + let lines = text.split(whereSeparator: { $0.isNewline }) + // Skip header if present + for (idx, raw) in lines.enumerated() { + let line = raw.trimmingCharacters(in: .whitespacesAndNewlines) + if line.isEmpty { continue } + if idx == 0 && line.lowercased().contains("relay url") { continue } + let parts = line.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } + guard parts.count >= 3 else { continue } + let host = parts[0].replacingOccurrences(of: "wss://", with: "") + guard let lat = Double(parts[1]), let lon = Double(parts[2]) else { continue } + result.insert(Entry(host: host, lat: lat, lon: lon)) + } + return Array(result) + } +} + +// MARK: - Distance +private func haversineKm(_ lat1: Double, _ lon1: Double, _ lat2: Double, _ lon2: Double) -> Double { + let r = 6371.0 // Earth radius in km + let dLat = (lat2 - lat1) * .pi / 180 + let dLon = (lon2 - lon1) * .pi / 180 + let a = sin(dLat/2) * sin(dLat/2) + cos(lat1 * .pi/180) * cos(lat2 * .pi/180) * sin(dLon/2) * sin(dLon/2) + let c = 2 * atan2(sqrt(a), sqrt(1 - a)) + return r * c +} diff --git a/bitchat/Nostr/NostrRelayManager.swift b/bitchat/Nostr/NostrRelayManager.swift index 40b7446c..d4734eee 100644 --- a/bitchat/Nostr/NostrRelayManager.swift +++ b/bitchat/Nostr/NostrRelayManager.swift @@ -78,9 +78,23 @@ class NostrRelayManager: ObservableObject { updateConnectionStatus() } + /// Ensure connections exist to the given relay URLs (idempotent). + func ensureConnections(to relayUrls: [String]) { + let existing = Set(relays.map { $0.url }) + for url in Set(relayUrls) { + if !existing.contains(url) { + relays.append(Relay(url: url)) + } + if connections[url] == nil { + connectToRelay(url) + } + } + } + /// Send an event to specified relays (or all if none specified) func sendEvent(_ event: NostrEvent, to relayUrls: [String]? = nil) { - let targetRelays = relayUrls ?? relays.map { $0.url } + let targetRelays = relayUrls ?? Self.defaultRelays + ensureConnections(to: targetRelays) // Add to queue for reliability messageQueueLock.lock() @@ -95,10 +109,11 @@ class NostrRelayManager: ObservableObject { } } - /// Subscribe to events matching a filter + /// Subscribe to events matching a filter. If `relayUrls` provided, targets only those relays. func subscribe( filter: NostrFilter, id: String = UUID().uuidString, + relayUrls: [String]? = nil, handler: @escaping (NostrEvent) -> Void ) { messageHandlers[id] = handler @@ -120,8 +135,14 @@ class NostrRelayManager: ObservableObject { // SecureLogger.log("📋 Subscription filter JSON: \(messageString.prefix(200))...", // category: SecureLogger.session, level: .debug) - // Send subscription to all connected relays - for (relayUrl, connection) in connections { + // Target specific relays if provided; else all connections + let urls = relayUrls ?? Self.defaultRelays + ensureConnections(to: urls) + let targets: [(String, URLSessionWebSocketTask)] = urls.compactMap { url in + connections[url].map { (url, $0) } + } + + for (relayUrl, connection) in targets { connection.send(.string(messageString)) { error in if let error = error { SecureLogger.log("❌ Failed to send subscription to \(relayUrl): \(error)", diff --git a/bitchat/Protocols/Geohash.swift b/bitchat/Protocols/Geohash.swift index 815880ac..b109bb7f 100644 --- a/bitchat/Protocols/Geohash.swift +++ b/bitchat/Protocols/Geohash.swift @@ -4,6 +4,11 @@ import Foundation /// Encodes latitude/longitude to base32 geohash with a fixed precision. enum Geohash { private static let base32Chars = Array("0123456789bcdefghjkmnpqrstuvwxyz") + private static let base32Map: [Character: Int] = { + var map: [Character: Int] = [:] + for (i, c) in base32Chars.enumerated() { map[c] = i } + return map + }() /// Encodes the provided coordinates into a geohash string. /// - Parameters: @@ -56,5 +61,30 @@ enum Geohash { return String(geohash) } -} + /// Decodes a geohash into the center latitude/longitude of its bounding box. + /// - Parameter geohash: Base32 geohash string. + /// - Returns: (lat, lon) center coordinate. + static func decodeCenter(_ geohash: String) -> (lat: Double, lon: Double) { + var latInterval: (Double, Double) = (-90.0, 90.0) + var lonInterval: (Double, Double) = (-180.0, 180.0) + + var isEven = true + for ch in geohash.lowercased() { + guard let cd = base32Map[ch] else { continue } + for mask in [16, 8, 4, 2, 1] { + if isEven { + let mid = (lonInterval.0 + lonInterval.1) / 2 + if (cd & mask) != 0 { lonInterval.0 = mid } else { lonInterval.1 = mid } + } else { + let mid = (latInterval.0 + latInterval.1) / 2 + if (cd & mask) != 0 { latInterval.0 = mid } else { latInterval.1 = mid } + } + isEven.toggle() + } + } + let lat = (latInterval.0 + latInterval.1) / 2 + let lon = (lonInterval.0 + lonInterval.1) / 2 + return (lat, lon) + } +} diff --git a/bitchat/ViewModels/ChatViewModel.swift b/bitchat/ViewModels/ChatViewModel.swift index 545818e0..72e27c55 100644 --- a/bitchat/ViewModels/ChatViewModel.swift +++ b/bitchat/ViewModels/ChatViewModel.swift @@ -711,7 +711,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { // Unsubscribe + resubscribe NostrRelayManager.shared.unsubscribe(id: subID) let filter = NostrFilter.geohashEphemeral(ch.geohash, since: Date().addingTimeInterval(-3600), limit: 200) - NostrRelayManager.shared.subscribe(filter: filter, id: subID) { [weak self] event in + let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) + NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in guard let self = self else { return } guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } if self.processedNostrEvents.contains(event.id) { return } @@ -1275,7 +1276,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate { nickname: self.nickname, teleported: LocationChannelManager.shared.teleported ) - NostrRelayManager.shared.sendEvent(event) + let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) + if targetRelays.isEmpty { + SecureLogger.log("Geo: no geohash relays available for \(ch.geohash); not sending", category: SecureLogger.session, level: .warning) + } else { + NostrRelayManager.shared.sendEvent(event, to: targetRelays) + } // Track ourselves as active participant self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) SecureLogger.log("GeoTeleport: sent geo message pub=\(identity.publicKeyHex.prefix(8))… teleported=\(LocationChannelManager.shared.teleported)", @@ -1359,7 +1365,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { geoSubscriptionID = subID startGeoParticipantsTimer() let filter = NostrFilter.geohashEphemeral(ch.geohash, since: Date().addingTimeInterval(-3600), limit: 200) - NostrRelayManager.shared.subscribe(filter: filter, id: subID) { [weak self] event in + let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) + NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in guard let self = self else { return } // Only handle ephemeral kind 20000 with matching tag guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } @@ -1730,7 +1737,8 @@ class ChatViewModel: ObservableObject, BitchatDelegate { let subID = "geo-sample-\(gh)" geoSamplingSubs[subID] = gh let filter = NostrFilter.geohashEphemeral(gh, since: Date().addingTimeInterval(-300), limit: 100) - NostrRelayManager.shared.subscribe(filter: filter, id: subID) { [weak self] event in + let subRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: gh, count: 5) + NostrRelayManager.shared.subscribe(filter: filter, id: subID, relayUrls: subRelays) { [weak self] event in guard let self = self else { return } guard event.kind == NostrProtocol.EventKind.ephemeralEvent.rawValue else { return } // Update participants for this specific geohash @@ -2478,7 +2486,17 @@ class ChatViewModel: ObservableObject, BitchatDelegate { nickname: self.nickname, teleported: LocationChannelManager.shared.teleported ) - NostrRelayManager.shared.sendEvent(event) + let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) + if targetRelays.isEmpty { + let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) + if targetRelays.isEmpty { + SecureLogger.log("Geo: no geohash relays available for \(ch.geohash); not sending", category: SecureLogger.session, level: .warning) + } else { + NostrRelayManager.shared.sendEvent(event, to: targetRelays) + } + } else { + NostrRelayManager.shared.sendEvent(event, to: targetRelays) + } // Track ourselves as active participant self.recordGeoParticipant(pubkeyHex: identity.publicKeyHex) } catch { @@ -4276,7 +4294,12 @@ class ChatViewModel: ObservableObject, BitchatDelegate { nickname: self.nickname, teleported: LocationChannelManager.shared.teleported ) - NostrRelayManager.shared.sendEvent(event) + let targetRelays = GeoRelayDirectory.shared.closestRelays(toGeohash: ch.geohash, count: 5) + if targetRelays.isEmpty { + NostrRelayManager.shared.sendEvent(event) + } else { + NostrRelayManager.shared.sendEvent(event, to: targetRelays) + } } catch { SecureLogger.log("❌ Failed to send geohash raw message: \(error)", category: SecureLogger.session, level: .error) } diff --git a/relays/online_relays_gps.csv b/relays/online_relays_gps.csv new file mode 100644 index 00000000..bd95f740 --- /dev/null +++ b/relays/online_relays_gps.csv @@ -0,0 +1,293 @@ +Relay URL,Latitude,Longitude +relay.damus.io,37.7621,-122.3971 +nostr-pub.wellorder.net,45.5229,-122.9898 +nostr.mom,50.4779,12.3713 +nostr.slothy.win,37.7621,-122.3971 +nostr.einundzwanzig.space,50.1155,8.6842 +nos.lol,50.4779,12.3713 +relay.nostr.band,60.1695,24.9354 +no.str.cr,9.9339,-84.0849 +nostr.massmux.com,50.1155,8.6842 +nostr-relay.schnitzel.world,39.0437,-77.4875 +relay.nostr.com.au,37.7621,-122.3971 +knostr.neutrine.com,48.8534,2.3488 +nostr.nodeofsven.com,47.4875,8.2965 +nostr.vulpem.com,49.4542,11.0775 +nostr-verif.slothy.win,37.7621,-122.3971 +relay.lexingtonbitcoin.org,37.7621,-122.3971 +nostr-1.nbo.angani.co,-1.2615,36.7903 +relay.wellorder.net,45.5229,-122.9898 +nostr.easydns.ca,43.7064,-79.3986 +relay.dwadziesciajeden.pl,52.2298,21.0118 +nostr.data.haus,50.4779,12.3713 +nostr.einundzwanzig.space,50.1155,8.6842 +nostr.mom,50.4779,12.3713 +nos.lol,50.4779,12.3713 +relay.nostr.band,60.1695,24.9354 +nostr.massmux.com,50.1155,8.6842 +nostr.vulpem.com,49.4542,11.0775 +relay.damus.io,37.7621,-122.3971 +nostr-pub.wellorder.net,45.5229,-122.9898 +no.str.cr,9.9339,-84.0849 +relay.dwadziesciajeden.pl,52.2298,21.0118 +nostr.data.haus,50.4779,12.3713 +relay.wellorder.net,45.5229,-122.9898 +relay.nostromo.social,49.4542,11.0775 +offchain.pub,34.0522,-118.2437 +relay.nostr.wirednet.jp,35.9356,139.3044 +relay.nostrcheck.me,37.7621,-122.3971 +nostrue.com,40.8043,-74.0121 +nostr-relay.schnitzel.world,39.0437,-77.4875 +nproxy.kristapsk.lv,60.1695,24.9354 +nostr.spaceshell.xyz,37.7621,-122.3971 +nostr-dev.wellorder.net,45.5229,-122.9898 +nostr-verified.wellorder.net,45.5229,-122.9898 +nostr.roundrockbitcoiners.com,40.8043,-74.0121 +slick.mjex.me,39.0437,-77.4875 +nostr.yael.at,52.3740,4.8897 +relay.primal.net,37.7621,-122.3971 +nostr.oxtr.dev,50.4779,12.3713 +nostr.21crypto.ch,46.5160,6.6328 +nostr.liberty.fans,38.8003,-90.6265 +nostr-02.dorafactory.org,1.2897,103.8501 +relay.hodl.ar,-32.9468,-60.6393 +nostr.middling.mydns.jp,35.8089,140.1185 +nostr.namek.link,37.7621,-122.3971 +nostrja-kari.heguro.com,37.7621,-122.3971 +nostr.hifish.org,47.3667,8.5500 +nostr.rikmeijer.nl,50.4779,12.3713 +black.nostrcity.club,41.8119,-87.6873 +nostr.hekster.org,37.3924,-121.9623 +relay.wavlake.com,41.2619,-95.8608 +nostr.sagaciousd.com,49.2497,-123.1193 +nostr.fbxl.net,43.7064,-79.3986 +ithurtswhenip.ee,50.7990,-1.0913 +relay2.nostrchat.io,49.4542,11.0775 +relay1.nostrchat.io,60.1695,24.9354 +nostr-01.yakihonne.com,1.3215,103.6957 +nostr.sathoarder.com,48.5839,7.7455 +nostr.overmind.lol,37.7621,-122.3971 +relay.verified-nostr.com,37.7621,-122.3971 +purplerelay.com,50.1155,8.6842 +relay.orangepill.ovh,49.0127,1.9694 +nostr-relay.psfoundation.info,39.0437,-77.4875 +soloco.nl,37.7621,-122.3971 +relay.froth.zone,60.1695,24.9354 +nostr.stakey.net,52.5250,5.7181 +nostr.2b9t.xyz,34.0522,-118.2437 +pyramid.fiatjaf.com,50.1155,8.6842 +a.nos.lol,50.4779,12.3713 +relay.magiccity.live,25.8130,-80.2320 +nostr.notribe.net,40.8344,-74.1377 +freelay.sovbit.host,64.1355,-21.8954 +relay.credenso.cafe,43.4254,-80.5112 +nostr.huszonegy.world,47.4984,19.0404 +multiplexer.huszonegy.world,47.4984,19.0404 +bucket.coracle.social,37.7621,-122.3971 +nostr.kungfu-g.rip,33.7865,-84.4454 +relay.artx.market,43.7064,-79.3986 +relay.notoshi.win,13.3622,100.9835 +vitor.nostr1.com,40.7143,-74.0060 +nostr-02.yakihonne.com,1.3215,103.6957 +nostr-03.dorafactory.org,1.2897,103.8501 +n.ok0.org,-36.8485,174.7635 +nostr.0x7e.xyz,47.5056,8.7241 +relay.nostr.net,50.4779,12.3713 +strfry.openhoofd.nl,51.5717,3.7042 +relay.fountain.fm,39.0997,-94.5786 +relay.usefusion.ai,38.7135,-78.1594 +relay.varke.eu,52.6958,6.1944 +nostr.satstralia.com,64.1355,-21.8954 +relay.13room.space,37.7621,-122.3971 +nostr.myshosholoza.co.za,52.3710,4.9042 +nostr.carroarmato0.be,50.8517,3.6089 +nostr.dbtc.link,37.7621,-122.3971 +orangepiller.org,60.1695,24.9354 +adre.su,59.9386,30.3141 +relay.sincensura.org,37.7621,-122.3971 +relay.freeplace.nl,52.3740,4.8897 +bostr.bitcointxoko.com,64.1355,-21.8954 +nostr.plantroon.com,50.1025,8.6299 +srtrelay.c-stellar.net,37.7621,-122.3971 +nostr.jfischer.org,49.4453,11.0222 +nostr.novacisko.cz,52.2298,21.0118 +relay.lumina.rocks,49.4453,11.0222 +nostr.tavux.tech,50.9519,1.8563 +relay.nostrhub.fr,50.1155,8.6842 +relay.agorist.space,52.3740,4.8897 +chorus.pjv.me,45.5229,-122.9898 +relay.cosmicbolt.net,37.3924,-121.9623 +santo.iguanatech.net,40.8344,-74.1377 +relay.tagayasu.xyz,45.4112,-75.6981 +relay.mostro.network,40.8344,-74.1377 +relay.zone667.com,60.1695,24.9354 +relay5.bitransfer.org,37.7621,-122.3971 +relay.illuminodes.com,47.6062,-122.3321 +relay2.angor.io,50.1155,8.6842 +relay.satsdays.com,1.2897,103.8501 +relay.angor.io,50.1155,8.6842 +orangesync.tech,50.9333,6.9500 +nostr-relay.cbrx.io,37.7621,-122.3971 +relay.21e6.cz,50.0880,14.4208 +nostr.chaima.info,50.1155,8.6842 +relay.satlantis.io,32.8546,-79.9748 +relay.digitalezukunft.cyou,45.5088,-73.5878 +relay.tapestry.ninja,40.8043,-74.0121 +relay.minibolt.info,37.7621,-122.3971 +nostr.bilthon.dev,25.8130,-80.2320 +nostr.makibisskey.work,37.7621,-122.3971 +relay.mattybs.lol,37.7621,-122.3971 +noxir.kpherox.dev,34.8436,135.5084 +sendit.nosflare.com,37.7621,-122.3971 +relay.coinos.io,37.7621,-122.3971 +relay.nostraddress.com,37.7621,-122.3971 +wot.nostr.party,36.1659,-86.7844 +nostrelites.org,41.8500,-87.6500 +relay.nostriot.com,43.7064,-79.3986 +prl.plus,55.7522,37.6156 +zap.watch,45.5088,-73.5878 +wot.codingarena.top,50.4779,12.3713 +nostr.azzamo.net,52.2284,21.0522 +wot.sudocarlos.com,43.7064,-79.3986 +relay.lnfi.network,45.6241,8.7851 +wot.nostr.net,37.7621,-122.3971 +relay.nostrdice.com,-33.8678,151.2073 +wot.sebastix.social,51.3700,6.1681 +wheat.happytavern.co,37.7621,-122.3971 +relay.sigit.io,50.4779,12.3713 +strfry.bonsai.com,37.8716,-122.2728 +travis-shears-nostr-relay-v2.fly.dev,41.8119,-87.6873 +satsage.xyz,37.3924,-121.9623 +relay.degmods.com,50.4779,12.3713 +nostr.community.ath.cx,45.5088,-73.5878 +nostr.coincrowd.fund,39.0437,-77.4875 +strfry.shock.network,41.8847,-88.2040 +cyberspace.nostr1.com,40.7143,-74.0060 +relay02.lnfi.network,39.0997,-94.5786 +nostr-rs-relay.dev.fedibtc.com,39.0437,-77.4875 +relay.davidebtc.me,50.1155,8.6842 +wot.dtonon.com,37.7621,-122.3971 +relay.goodmorningbitcoin.com,37.7621,-122.3971 +articles.layer3.news,37.3394,-121.8950 +bostr.syobon.net,37.7621,-122.3971 +nostr.agentcampfire.com,52.3740,4.8897 +nostr.thebiglake.org,32.7244,-96.6755 +schnorr.me,37.7621,-122.3971 +relay.wolfcoil.com,35.6090,139.7302 +nostr.camalolo.com,24.1469,120.6839 +nostr.tac.lol,47.4740,-122.2610 +dev-relay.lnfi.network,39.0997,-94.5786 +relay.bitcoinveneto.org,64.1355,-21.8954 +nostr.red5d.dev,37.7621,-122.3971 +relay-testnet.k8s.layer3.news,37.3394,-121.8950 +promenade.fiatjaf.com,50.1155,8.6842 +nostrelay.memory-art.xyz,37.7621,-122.3971 +inbox.azzamo.net,52.2284,21.0522 +social.proxymana.net,60.1695,24.9354 +relay.netstr.io,53.3331,-6.2489 +premium.primal.net,37.7621,-122.3971 +nostr.lojong.info,37.7621,-122.3971 +nostr-rs-relay-ishosta.phamthanh.me,37.7621,-122.3971 +relay.stream.labs.h3.se,59.3294,18.0687 +tollbooth.stens.dev,51.4566,7.0123 +relay.chakany.systems,37.7621,-122.3971 +relay.mwaters.net,50.9519,1.8563 +nostr-relay.shirogaku.xyz,37.7621,-122.3971 +kitchen.zap.cooking,37.7621,-122.3971 +relay.arx-ccn.com,50.4779,12.3713 +relay.fr13nd5.com,50.1155,8.6842 +nostr.tegila.com.br,39.0437,-77.4875 +relay.jeffg.fyi,43.7064,-79.3986 +relay.bullishbounty.com,37.7621,-122.3971 +nostr.spicyz.io,37.7621,-122.3971 +relay04.lnfi.network,39.0997,-94.5786 +vidono.apps.slidestr.net,48.8534,2.3488 +relay03.lnfi.network,39.0997,-94.5786 +communities.nos.social,40.8344,-74.1377 +relay.evanverma.com,40.8344,-74.1377 +nostrelay.circum.space,51.4566,7.0123 +wot.brightbolt.net,47.6928,-116.7850 +relayrs.notoshi.win,37.7621,-122.3971 +fenrir-s.notoshi.win,37.7621,-122.3971 +relay.nsnip.io,60.1695,24.9354 +x.kojira.io,37.7621,-122.3971 +relay.hasenpfeffr.com,39.0437,-77.4875 +relay01.lnfi.network,39.0997,-94.5786 +nostr.rtvslawenia.com,49.4542,11.0775 +relay.g1sms.fr,43.9298,2.1480 +nostr.kalf.org,52.3740,4.8897 +nostr.rblb.it,37.7621,-122.3971 +nostr.4rs.nl,49.4453,11.0222 +relay.vrtmrz.net,37.7621,-122.3971 +nostr.hoppe-relay.it.com,45.5946,-121.1787 +relay-rpi.edufeed.org,49.4453,11.0222 +relay.copylaradio.com,50.7990,-1.0913 +relay.ru.ac.th,13.7540,100.5014 +relay.bitcoinartclock.com,50.4779,12.3713 +wot.downisontheup.ca,47.6062,-122.3321 +nostr.coincards.com,43.7064,-79.3986 +relay.etch.social,41.2619,-95.8608 +relay.mess.ch,47.1345,9.0964 +relay.holzeis.me,37.7621,-122.3971 +relay-admin.thaliyal.com,40.8220,-74.4488 +nostr.thaliyal.com,40.8220,-74.4488 +strfry.felixzieger.de,50.1025,8.6299 +nostr.smut.cloud,37.7621,-122.3971 +r.bitcoinhold.net,37.7621,-122.3971 +nostr.blankfors.se,60.1695,24.9354 +portal-relay.pareto.space,49.4453,11.0222 +relay.getsafebox.app,43.7064,-79.3986 +relay.anzenkodo.workers.dev,37.7621,-122.3971 +relay.nostrhub.tech,49.4453,11.0222 +nostr.prl.plus,52.3740,4.8897 +nostr-2.21crypto.ch,46.5160,6.6328 +nostr.zenon.network,40.7143,-74.0060 +nostr-relay.amethyst.name,35.7721,-78.6386 +relayone.geektank.ai,17.1210,-61.8433 +fanfares.nostr1.com,40.7143,-74.0060 +wot.geektank.ai,17.1210,-61.8433 +relay-dev.satlantis.io,40.8344,-74.1377 +relay.siamdev.cc,13.9178,100.4240 +relay.nosto.re,51.3700,6.1681 +wot.soundhsa.com,39.0997,-94.5786 +nostr.n7ekb.net,47.5707,-122.2221 +relayone.soundhsa.com,39.0997,-94.5786 +relay.puresignal.news,37.7621,-122.3971 +relay.nostx.io,37.7621,-122.3971 +nostr.now,35.6090,139.7302 +relay.artiostr.ch,37.7621,-122.3971 +relay.oldenburg.cool,50.1155,8.6842 +theoutpost.life,64.1355,-21.8954 +khatru.nostrver.se,51.3700,6.1681 +relay.wavefunc.live,37.7915,-122.4018 +nostr-relay.zimage.com,34.0522,-118.2437 +relay.javi.space,43.4628,11.8807 +bostr.shop,42.8865,-78.8784 +relay.letsfo.com,52.2298,21.0118 +alien.macneilmediagroup.com,37.7621,-122.3971 +rn1.sotiras.org,37.7621,-122.3971 +gnostr.com,42.6975,23.3241 +relay.conduit.market,37.7621,-122.3971 +relay.hivetalk.org,37.3924,-121.9623 +nostr.l484.com,30.2960,-97.6396 +relay.chorus.community,50.1155,8.6842 +nostr-relay.moe.gift,37.7621,-122.3971 +relay.nostrcal.com,37.7621,-122.3971 +temp.iris.to,37.7621,-122.3971 +librerelay.aaroniumii.com,37.7621,-122.3971 +nostr-relay-1.trustlessenterprise.com,37.7621,-122.3971 +relay.barine.co,37.7621,-122.3971 +nostr.rohoss.com,48.1374,11.5755 +wot.nostr.place,30.2672,-97.7431 +relay.utxo.farm,34.7331,135.8183 +relay.bankless.at,37.7621,-122.3971 +relay.toastr.net,40.8043,-74.0121 +nostr.excentered.com,52.5244,13.4105 +relay.mccormick.cx,52.3740,4.8897 +relay.cypherflow.ai,48.8534,2.3488 +relay.laantungir.net,45.3134,-73.8725 +nostr.veladan.dev,37.7621,-122.3971 +nostr.tadryanom.me,37.7621,-122.3971 +nostr-relay.online,37.7621,-122.3971 +nostr.night7.space,50.4779,12.3713 +dev-nostr.bityacht.io,25.0531,121.5264