Merge 91b529eb7055d3e52b72a5f953abb1c9acd956ab into c127eb83ab94c069c32d37530d2faecd381cd2a8

This commit is contained in:
heyaim 2026-09-14 12:21:28 -05:00 committed by GitHub
commit 95e2b55ae1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 1273 additions and 44 deletions

View File

@ -19,9 +19,11 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Fetch GeoRelays
# Must match RelayDirectory.ASSET_FILE_URL, the file the app fetches at
# runtime; the bundled asset is a snapshot of the same file.
run: |
wget https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv
mv nostr_relays.csv ./app/src/main/assets/nostr_relays.csv
wget https://raw.githubusercontent.com/permissionlesstech/bitchat/refs/heads/main/relays/online_relays_gps.csv
mv online_relays_gps.csv ./app/src/main/assets/nostr_relays.csv
- name: Check for changes
id: git-check

View File

@ -3,12 +3,10 @@ package com.bitchat.android.nostr
import android.app.Application
import android.content.SharedPreferences
import android.util.Log
import java.io.BufferedReader
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.InputStream
import java.io.InputStreamReader
import java.security.MessageDigest
import java.util.concurrent.TimeUnit
import kotlin.math.*
@ -26,13 +24,33 @@ import okhttp3.Request
object RelayDirectory {
private const val TAG = "RelayDirectory"
private const val ASSET_FILE_URL = "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv"
// The same file iOS reads (GeoRelayDirectory.swift). Both platforms take the 5
// nearest relays from their directory and use them without the defaults, so a
// geohash message crosses platforms only if the two selections share a relay.
// Reading different files made the selections diverge. Selecting from the same
// file, with rows keyed and ordered the same way, keeps them aligned.
internal const val ASSET_FILE_URL = "https://raw.githubusercontent.com/permissionlesstech/bitchat/refs/heads/main/relays/online_relays_gps.csv"
// Download cache of ASSET_FILE_URL above, and the bundled list the weekly job
// refreshes from it; the file names predate the source's move to online_relays_gps.csv.
private const val ASSET_FILE = "nostr_relays.csv"
private const val DOWNLOADED_FILE = "nostr_relays_latest.csv"
private const val PREFS_NAME = "relay_directory_prefs"
private const val KEY_LAST_UPDATE_MS = "last_update_ms"
private const val KEY_SOURCE_URL = "source_url"
private val ONE_DAY_MS = TimeUnit.DAYS.toMillis(1)
// GeoRelayDirectoryValidationPolicy.live, ported verbatim. The directory is an
// unsigned third-party file (docs/security-review-jul-27.md M9); iOS bounds what
// it will accept from it and rejects the rest, and both platforms must bound it
// the same way or a file one side accepts and the other rejects splits their
// relay selections at every geohash at once.
internal const val MAX_DIRECTORY_BYTES = 512 * 1024
internal const val MAX_DIRECTORY_ROWS = 5_000
internal const val MAX_DIRECTORY_ENTRIES = 5_000
internal const val MIN_REMOTE_ENTRIES = 50
internal const val MIN_RETAINED_FRACTION = 0.5
private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val httpClient: OkHttpClient
get() = com.bitchat.android.net.OkHttpProvider.httpClient()
@ -55,6 +73,7 @@ object RelayDirectory {
if (initialized) return
try {
val downloaded = getDownloadedFile(application)
invalidateCacheIfSourceChanged(getPrefs(application), downloaded)
val loadedFromDownloaded = if (downloaded.exists() && downloaded.canRead()) {
loadFromFile(downloaded, sourceLabel = "downloaded")
} else {
@ -97,14 +116,21 @@ object RelayDirectory {
}
val (lat, lon) = center
return snapshot
.asSequence()
.sortedBy { haversineMeters(lat, lon, it.latitude, it.longitude) }
.take(nRelays.coerceAtLeast(0))
.map { it.url }
.toList()
return closestRelays(snapshot, lat, lon, nRelays)
}
// Distance ties are the directory's common case, not an edge: rows are geocoded
// to city centroids, so whole groups of relays sit at one exact coordinate. iOS
// breaks ties by host so every device with the same directory picks the same set
// (GeoRelayDirectory.closestRelays). Urls here are canonical hosts behind a fixed
// prefix, so ordering by url reproduces iOS's order.
internal fun closestRelays(entries: List<RelayInfo>, lat: Double, lon: Double, nRelays: Int): List<String> =
entries
.map { it to haversineMeters(lat, lon, it.latitude, it.longitude) }
.sortedWith(compareBy({ it.second }, { it.first.url }))
.take(nRelays.coerceAtLeast(0))
.map { it.first.url }
private fun haversineMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double {
val R = 6371000.0 // meters
val dLat = Math.toRadians(lat2 - lat1)
@ -114,17 +140,26 @@ object RelayDirectory {
return R * c
}
private fun normalizeRelayUrl(raw: String): String {
val trimmed = raw.trim()
if (trimmed.isEmpty()) return trimmed
return if ("://" in trimmed) trimmed else "wss://$trimmed"
}
// ===== Implementation details =====
private fun getPrefs(application: Application): SharedPreferences =
application.getSharedPreferences(PREFS_NAME, Application.MODE_PRIVATE)
/**
* An install upgraded across the source move still holds a cache fetched from
* the old URL. Drop it and clear the update stamp, and the staleness check
* refetches, rather than keep selecting from a file the current source no
* longer matches. Returns whether a cache was dropped.
*/
internal fun invalidateCacheIfSourceChanged(prefs: SharedPreferences, downloaded: File): Boolean {
if (!downloaded.exists()) return false
if (prefs.getString(KEY_SOURCE_URL, null) == ASSET_FILE_URL) return false
downloaded.delete()
prefs.edit().remove(KEY_LAST_UPDATE_MS).apply()
Log.i(TAG, "Dropped cached relay list fetched from a previous source URL")
return true
}
private fun getDownloadedFile(application: Application): File =
File(application.filesDir, DOWNLOADED_FILE)
@ -159,9 +194,17 @@ object RelayDirectory {
return
}
val parsed = parseCsv(FileInputStream(tmpFile))
if (parsed.isEmpty()) {
Log.w(TAG, "Downloaded relay CSV parsed to 0 entries; ignoring")
if (tmpFile.length() > MAX_DIRECTORY_BYTES) {
Log.w(TAG, "Downloaded relay CSV exceeds $MAX_DIRECTORY_BYTES bytes; keeping current list")
tmpFile.delete()
return
}
// The current directory is the baseline: a rejected download keeps it,
// in memory and on disk, the way iOS keeps its previous copy.
val baseline = synchronized(relaysLock) { relays.toSet() }
val parsed = validatedEntries(tmpFile.readBytes(), MIN_REMOTE_ENTRIES, baseline)
if (parsed == null) {
Log.w(TAG, "Downloaded relay CSV failed validation; keeping current list")
tmpFile.delete()
return
}
@ -182,7 +225,10 @@ object RelayDirectory {
relays.addAll(parsed)
}
getPrefs(application).edit().putLong(KEY_LAST_UPDATE_MS, System.currentTimeMillis()).apply()
getPrefs(application).edit()
.putLong(KEY_LAST_UPDATE_MS, System.currentTimeMillis())
.putString(KEY_SOURCE_URL, ASSET_FILE_URL)
.apply()
Log.i(TAG, "✅ Using downloaded relay list (${dest.absolutePath}), entries=$entries, sha256=$hash, updatedAtMs=${getPrefs(application).getLong(KEY_LAST_UPDATE_MS, 0L)}")
} catch (e: Exception) {
@ -199,9 +245,24 @@ object RelayDirectory {
return false
}
val body = resp.body ?: return false
if (body.contentLength() > MAX_DIRECTORY_BYTES) {
Log.w(TAG, "Relay CSV content length exceeds $MAX_DIRECTORY_BYTES bytes; aborting")
return false
}
FileOutputStream(dest).use { out ->
body.byteStream().use { input ->
input.copyTo(out)
val buf = ByteArray(8192)
var total = 0L
while (true) {
val read = input.read(buf)
if (read <= 0) break
total += read
if (total > MAX_DIRECTORY_BYTES.toLong()) {
Log.w(TAG, "Relay CSV download exceeded $MAX_DIRECTORY_BYTES bytes; aborting")
return false
}
out.write(buf, 0, read)
}
}
}
true
@ -214,9 +275,9 @@ object RelayDirectory {
private fun loadFromFile(file: File, sourceLabel: String): Boolean {
return try {
val list = parseCsv(FileInputStream(file))
if (list.isEmpty()) {
Log.w(TAG, "${sourceLabel} relay CSV has 0 entries; ignoring")
val list = validatedEntries(file.readBytes(), minimumEntries = 1)
if (list == null) {
Log.w(TAG, "${sourceLabel} relay CSV failed validation; ignoring it")
false
} else {
synchronized(relaysLock) {
@ -235,7 +296,11 @@ object RelayDirectory {
private fun loadFromAssets(application: Application) {
val list = try {
parseCsv(application.assets.open(ASSET_FILE))
val bytes = application.assets.open(ASSET_FILE).use { it.readBytes() }
validatedEntries(bytes, minimumEntries = 1) ?: run {
Log.e(TAG, "Bundled asset $ASSET_FILE failed validation")
emptyList()
}
} catch (e: Exception) {
Log.e(TAG, "Failed to open asset $ASSET_FILE: ${e.message}")
emptyList()
@ -255,26 +320,248 @@ object RelayDirectory {
Log.i(TAG, "📦 Loaded ${list.size} relay entries from assets/$ASSET_FILE, sha256=$hash")
}
private fun parseCsv(input: InputStream): List<RelayInfo> {
val result = mutableListOf<RelayInfo>()
BufferedReader(InputStreamReader(input)).use { reader ->
var line: String?
while (true) {
line = reader.readLine()
if (line == null) break
val trimmed = line!!.trim()
if (trimmed.isEmpty()) continue
if (trimmed.lowercase().startsWith("relay url")) continue
val parts = trimmed.split(",")
if (parts.size < 3) continue
val url = normalizeRelayUrl(parts[0].trim())
val lat = parts[1].trim().toDoubleOrNull()
val lon = parts[2].trim().toDoubleOrNull()
if (url.isEmpty() || lat == null || lon == null) continue
result.add(RelayInfo(url = url, latitude = lat, longitude = lon))
/**
* GeoRelayDirectory.validatedEntries, ported in full. One malformed or
* conflicting row rejects the complete dataset, and the caller keeps whatever
* directory it already has: a partial parse would leave this client selecting
* from a different row set than iOS, the failure #914 exists to close. Returns
* null when the data is rejected.
*
* baselineEntries carries the current directory when validating a download. A
* new file that keeps less than half of the known entries is rejected even when
* well formed, matching iOS: a hijacked or truncated upstream cannot swap the
* whole relay population in one fetch.
*/
internal fun validatedEntries(
data: ByteArray,
minimumEntries: Int,
baselineEntries: Set<RelayInfo>? = null
): List<RelayInfo>? {
if (data.isEmpty() || data.size > MAX_DIRECTORY_BYTES) return null
var text = decodeUtf8Strict(data) ?: return null
// Foundation's UTF-8 decode strips one leading BOM before iOS's own BOM
// check runs, so a single BOM passes on iOS and only a doubled one is
// rejected. Mirror that exactly (verified against the real Swift code).
if (text.startsWith('\uFEFF')) text = text.substring(1)
if (text.startsWith('\uFEFF')) return null
val lines = text
.split('\u000A', '\u000B', '\u000C', '\u000D', '\u0085', '\u2028', '\u2029')
.map { it.trim() }
.filter { it.isNotEmpty() }
val header = lines.firstOrNull() ?: return null
if (lines.size - 1 > MAX_DIRECTORY_ROWS) return null
val headerParts = header.split(",").map { it.trim().lowercase() }
val supportedHeaders = listOf(
listOf("relay url", "latitude", "longitude"),
listOf("relay url", "lat", "lon")
)
if (headerParts !in supportedHeaders) return null
val entriesByHost = LinkedHashMap<String, RelayInfo>()
for (line in lines.drop(1)) {
val parts = line.split(",").map { it.trim() }
if (parts.size != 3) return null
val host = validatedDirectoryAddress(parts[0]) ?: return null
val latitude = parseCoordinate(parts[1]) ?: return null
if (latitude !in -90.0..90.0) return null
val longitude = parseCoordinate(parts[2]) ?: return null
if (longitude !in -180.0..180.0) return null
val entry = RelayInfo(url = "wss://$host", latitude = latitude, longitude = longitude)
val existing = entriesByHost[host]
// One endpoint cannot truthfully occupy two coordinates. Matching iOS,
// row order does not get to choose which location clients trust. The
// comparison is IEEE equality, not equals(): Swift's == calls -0.0 and
// 0.0 the same coordinate, and the last equal row's value is kept, raw
// bits included, the way Swift dictionary assignment keeps it.
if (existing != null && !sameEntry(existing, entry)) {
return null
}
entriesByHost[host] = entry
if (entriesByHost.size > MAX_DIRECTORY_ENTRIES) return null
}
val parsed = entriesByHost.values.toList()
if (parsed.size < minimumEntries) return null
if (baselineEntries != null) {
val required = ceil(baselineEntries.size * MIN_RETAINED_FRACTION).toInt()
val overlap = parsed.count { p -> baselineEntries.any { b -> sameEntry(p, b) } }
if (overlap < required) return null
}
return parsed.sortedWith(compareBy({ it.url }, { it.latitude }, { it.longitude }))
}
/**
* GeoRelayDirectory.validatedDirectoryAddress, ported in full: the host
* key both platforms build for a row, or null when the address is one the
* directory must not carry (non-ASCII, credentials, paths, queries, local and
* internal names, malformed labels, out-of-range ports). An explicit port stays
* in the key unless it is 443, the wss default, which keeps a relay on :8443
* distinct and collapses the directory's bare and :443 duplicate rows. Dedup
* and tie ordering both key on this, which is what keeps the two platforms'
* selections aligned row for row.
*/
internal fun validatedDirectoryAddress(rawValue: String): String? {
val value = rawValue.trim()
if (value.isEmpty()) return null
if (!value.all { it.code in 0x20..0x7E }) return null
val encoded = if ("://" in value) value else "wss://$value"
// URLComponents percent-decodes before iOS's checks run, so re%6Cay.example
// is relay.example to iOS; java.net.URI does not decode. Decode the same
// way first, and reject invalid escapes the way URLComponents rejects them.
val candidate = percentDecodedOrNull(encoded) ?: return null
val uri = try { java.net.URI(candidate) } catch (_: Exception) { return null }
val scheme = uri.scheme?.lowercase() ?: return null
if (scheme != "wss" && scheme != "https") return null
if (uri.userInfo != null) return null
if (uri.query != null) return null
if (uri.fragment != null) return null
val path = uri.path ?: ""
if (path.isNotEmpty() && path != "/") return null
// java.net.URI follows RFC 2396, which requires the final host label to
// start with a letter, and returns no host for names like b.08obllot
// that iOS's RFC 3986 parser accepts. When URI refuses only for that
// reason, a plain hostname[:port] authority is taken as the host and
// the screens below judge it; fuzzing found this as the dominant
// divergence class (Android rejecting what iOS accepts).
val rawHost = uri.host
?: plainAuthorityHost(candidate)
?: return null
val host = rawHost.lowercase()
if (host.isEmpty() || host.length > 253) return null
if (!host.all { it.code <= 0x7F }) return null
if (host.endsWith(".")) return null
if (host == "localhost" || host.endsWith(".localhost") ||
host.endsWith(".local") || host.endsWith(".internal")) return null
val labels = host.split(".")
if (labels.size < 2) return null
// URLComponents IDNA-decodes xn-- labels: valid punycode decodes to
// non-ASCII and fails iOS's screen, invalid punycode fails its parse, and
// both reject the address (verified against the real validator). java.net
// URI passes the label through, so the label form itself is refused here.
if (labels.any { it.startsWith("xn--") }) return null
if (labels.all { label -> label.all { it.isDigit() } }) return null
if (!labels.all { label ->
label.length in 1..63 && label.first() != '-' && label.last() != '-' &&
label.all { it in 'a'..'z' || it in '0'..'9' || it == '-' }
}
) return null
val port = if (uri.host != null) uri.port else plainAuthorityPort(candidate)
if (port != -1) {
if (port !in 1..65535) return null
if (port != 443) return "$host:$port"
}
return host
}
// The authority of the candidate when it is nothing but hostname[:port] in
// the host charset. Anything with userinfo, brackets, escapes, or other
// structure stays with java.net.URI's verdict.
private fun plainAuthority(candidate: String): Pair<String, Int>? {
val afterScheme = candidate.substringAfter("://", "")
if (afterScheme.isEmpty()) return null
val authority = afterScheme.takeWhile { it != '/' && it != '?' && it != '#' }
if (authority.length != afterScheme.length) {
val rest = afterScheme.substring(authority.length)
if (rest != "/") return null
}
val colon = authority.lastIndexOf(':')
val hostPart: String
val port: Int
if (colon >= 0) {
val portPart = authority.substring(colon + 1)
// RFC 3986 allows an empty port ("host:"), and Foundation treats it
// as no port at all.
if (portPart.isNotEmpty() && !portPart.all { it in '0'..'9' }) return null
hostPart = authority.substring(0, colon)
port = if (portPart.isEmpty()) -1 else portPart.toIntOrNull() ?: return null
} else {
hostPart = authority
port = -1
}
if (hostPart.isEmpty()) return null
if (!hostPart.all { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '.' || it == '-' }) return null
return hostPart to port
}
private fun plainAuthorityHost(candidate: String): String? = plainAuthority(candidate)?.first
private fun plainAuthorityPort(candidate: String): Int = plainAuthority(candidate)?.second ?: -1
// IEEE equality on the coordinates: -0.0 equals 0.0 here, as it does in the
// Swift Entry's ==, where equals() would call them different.
private fun sameEntry(a: RelayInfo, b: RelayInfo): Boolean =
a.url == b.url && a.latitude == b.latitude && a.longitude == b.longitude
// Strict %XX decoding with UTF-8 byte semantics and no '+' handling. Returns
// null on an invalid or truncated escape, matching URLComponents.
private fun percentDecodedOrNull(value: String): String? {
if ('%' !in value) return value
val bytes = java.io.ByteArrayOutputStream(value.length)
var i = 0
while (i < value.length) {
val c = value[i]
if (c == '%') {
if (i + 2 >= value.length) return null
val hi = Character.digit(value[i + 1], 16)
val lo = Character.digit(value[i + 2], 16)
if (hi < 0 || lo < 0) return null
val decoded = ((hi shl 4) or lo).toChar()
// URLComponents decodes AFTER structural parsing, so a decoded
// ':' stays inside the host and fails iOS's label screen; decoding
// it here first would instead create a port. Only escapes that
// decode to host-legal characters may pass (verified against the
// real validator: %6C and %2E accept, %3A and the rest reject).
if (!(decoded in 'A'..'Z' || decoded in 'a'..'z' ||
decoded in '0'..'9' || decoded == '.' || decoded == '-')) return null
bytes.write(decoded.code)
i += 3
} else {
bytes.write(c.code)
i += 1
}
}
return result
return decodeUtf8Strict(bytes.toByteArray())
}
// iOS String(data:encoding:.utf8) fails on invalid UTF-8 where Kotlin's
// String(bytes) substitutes replacement characters. Decode strictly so both
// platforms reject the same bytes.
private fun decodeUtf8Strict(data: ByteArray): String? = try {
Charsets.UTF_8.newDecoder()
.onMalformedInput(java.nio.charset.CodingErrorAction.REPORT)
.onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT)
.decode(java.nio.ByteBuffer.wrap(data))
.toString()
} catch (_: Exception) {
null
}
// Two Swift-vs-Java parsing differences, both verified against the real
// validator: Double.parseDouble takes trailing f/F/d/D suffixes that Swift
// rejects (and in hex those characters are digits, not suffixes), and Swift
// accepts hex like "0x10" without the binary exponent Java requires.
// Infinity and NaN spellings parse on both and fail the finite check.
private fun parseCoordinate(raw: String): Double? {
if (raw.isEmpty()) return null
val body = raw.removePrefix("+").removePrefix("-")
val isHex = body.startsWith("0x") || body.startsWith("0X")
if (!isHex) {
val last = raw.last()
if (last == 'f' || last == 'F' || last == 'd' || last == 'D') return null
}
val candidate = if (isHex && !raw.contains('p') && !raw.contains('P')) raw + "p0" else raw
val value = candidate.toDoubleOrNull() ?: return null
return if (value.isFinite()) value else null
}
private fun fileSha256Hex(file: File): String = try {

View File

@ -0,0 +1,82 @@
package com.bitchat.android.nostr
import android.app.Application
import android.content.Context
import android.os.Build
import androidx.test.core.app.ApplicationProvider
import java.io.File
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
/**
* A cached directory is only as current as the URL it was fetched from. An install
* upgraded across the source move still holds a cache from the old URL; these pin
* that it is dropped and refetched instead of selecting from stale data.
*/
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE)
class RelayDirectoryCacheInvalidationTest {
private val application: Application = ApplicationProvider.getApplicationContext()
private fun prefs() = application.getSharedPreferences("relay_directory_prefs", Context.MODE_PRIVATE)
private fun cacheFile(content: String = "stub"): File =
File(application.filesDir, "test_relay_cache.csv").apply { writeText(content) }
@Test
fun `a cache with no recorded source url is dropped`() {
val cache = cacheFile()
prefs().edit().clear().putLong("last_update_ms", 123L).commit()
val dropped = RelayDirectory.invalidateCacheIfSourceChanged(prefs(), cache)
assertTrue(dropped)
assertFalse(cache.exists())
assertFalse(prefs().contains("last_update_ms"))
}
@Test
fun `a cache recorded from a different source url is dropped`() {
val cache = cacheFile()
prefs().edit().clear()
.putString("source_url", "https://old.host.example/nostr_relays.csv")
.putLong("last_update_ms", 123L)
.commit()
val dropped = RelayDirectory.invalidateCacheIfSourceChanged(prefs(), cache)
assertTrue(dropped)
assertFalse(cache.exists())
}
@Test
fun `a cache recorded from the current source url is kept`() {
val cache = cacheFile()
prefs().edit().clear()
.putString("source_url", RelayDirectory.ASSET_FILE_URL)
.putLong("last_update_ms", 123L)
.commit()
val dropped = RelayDirectory.invalidateCacheIfSourceChanged(prefs(), cache)
assertFalse(dropped)
assertTrue(cache.exists())
assertTrue(prefs().contains("last_update_ms"))
}
@Test
fun `a missing cache changes nothing`() {
val cache = File(application.filesDir, "absent.csv")
prefs().edit().clear().putLong("last_update_ms", 123L).commit()
val dropped = RelayDirectory.invalidateCacheIfSourceChanged(prefs(), cache)
assertFalse(dropped)
assertTrue(prefs().contains("last_update_ms"))
}
}

View File

@ -0,0 +1,145 @@
package com.bitchat.android.nostr
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Pins the three properties cross-platform geohash delivery depends on. Both platforms
* take the 5 relays nearest to a geohash and use them without the default relays, so a
* geohash message crosses platforms only if the two selections intersect.
*
* 1. Android reads the SAME directory file iOS reads.
* 2. Rows are deduplicated and emitted by the same key iOS builds: host lowercased,
* an explicit port kept unless it is 443. The directory lists many relays twice,
* once bare and once as host:443, and both forms are one server over wss.
* 3. Distance ties order by that key, the way iOS orders them. Rows are geocoded to
* city centroids, so whole tie groups sit at one coordinate and tie order decides
* most selections.
*
* Parsing goes through the validator ported from GeoRelayDirectory.validatedEntries;
* RelayDirectoryValidationTest pins its acceptance rules. These tests parse files the
* validator accepts.
*/
class RelayDirectoryTest {
private fun parse(csv: String) =
requireNotNull(RelayDirectory.validatedEntries(csv.toByteArray(), minimumEntries = 1)) {
"fixture unexpectedly rejected"
}
private val header = "Relay URL,Latitude,Longitude\n"
@Test
fun `fetch url is the file ios reads`() {
assertEquals(
"https://raw.githubusercontent.com/permissionlesstech/bitchat/refs/heads/main/relays/online_relays_gps.csv",
RelayDirectory.ASSET_FILE_URL
)
}
@Test
fun `validated addresses match the key ios builds`() {
assertEquals("relay.example.com", RelayDirectory.validatedDirectoryAddress("wss://relay.example.com"))
assertEquals("relay.example.com", RelayDirectory.validatedDirectoryAddress("wss://relay.example.com:443"))
assertEquals("relay.example.com:8443", RelayDirectory.validatedDirectoryAddress("wss://relay.example.com:8443"))
assertEquals("relay.example.com", RelayDirectory.validatedDirectoryAddress("wss://Relay.Example.Com/"))
assertEquals("relay.example.com", RelayDirectory.validatedDirectoryAddress("relay.example.com"))
assertEquals("relay.example.com", RelayDirectory.validatedDirectoryAddress("https://relay.example.com"))
}
@Test
fun `port 443 variant of a host is the same server and is not listed twice`() {
val entries = parse(
header +
"relay.example.com,10.0,20.0\n" +
"relay.example.com:443,10.0,20.0\n"
)
assertEquals(1, entries.size)
assertEquals("wss://relay.example.com", entries[0].url)
}
@Test
fun `a nonstandard port is a different server and stays`() {
// The live directory lists some relays bare and on a nonstandard port.
// iOS keeps both too; it drops only an explicit 443.
val entries = parse(
header +
"port-variant.relay.example,1.0,1.0\n" +
"port-variant.relay.example:8443,1.0,1.0\n"
)
assertEquals(2, entries.size)
assertEquals("wss://port-variant.relay.example", entries[0].url)
assertEquals("wss://port-variant.relay.example:8443", entries[1].url)
}
@Test
fun `an endpoint listed at two different coordinates rejects the file`() {
// One endpoint cannot truthfully occupy two coordinates. iOS rejects the
// whole file rather than letting row order choose which location clients
// trust; the earlier first-row-wins behavior is gone with it.
val rejected = RelayDirectory.validatedEntries(
(header +
"relay.example.com,10.0,20.0\n" +
"relay.example.com:443,50.0,60.0\n").toByteArray(),
minimumEntries = 1
)
assertNull(rejected)
}
@Test
fun `host case does not create a second endpoint`() {
val entries = parse(
header +
"Relay.Example.Com:443,10.0,20.0\n" +
"relay.example.com,10.0,20.0\n"
)
assertEquals(1, entries.size)
assertEquals("wss://relay.example.com", entries[0].url)
}
@Test
fun `distance ties order by host the way ios orders them`() {
// The directory's dominant shape: a whole tie group at one shared coordinate
// (the live file has 137 rows at a single point). File order is
// deliberately not alphabetical; the selection must not depend on it.
val csv = header +
"delta.example.com,12.34,56.78\n" +
"foxtrot.example.com,12.34,56.78\n" +
"alpha.example.com:443,12.34,56.78\n" +
"echo.example.com,12.34,56.78\n" +
"bravo.example.com,12.34,56.78\n" +
"charlie.example.com,12.34,56.78\n"
val five = RelayDirectory.closestRelays(parse(csv), 12.34, 56.78, 5)
assertEquals(
listOf(
"wss://alpha.example.com",
"wss://bravo.example.com",
"wss://charlie.example.com",
"wss://delta.example.com",
"wss://echo.example.com"
),
five
)
}
@Test
fun `five nearest means five distinct servers`() {
// A shape the live directory produces: the nearest relay listed twice
// (bare and :443), which used to occupy two of the five selection slots and
// push out the fifth distinct server.
val csv = header +
"nearest.example.com,10.10,20.20\n" +
"nearest.example.com:443,10.10,20.20\n" +
"second.example.com,10.20,20.20\n" +
"third.example.com,10.30,20.20\n" +
"fourth.example.com,10.40,20.20\n" +
"fifth.example.com,10.50,20.20\n" +
"faraway.example.com,80.0,120.0\n"
val five = RelayDirectory.closestRelays(parse(csv), 10.10, 20.20, 5)
assertEquals(5, five.size)
assertEquals("every selected relay is a distinct server", 5, five.toSet().size)
assertTrue("the fifth distinct server makes the cut", five.contains("wss://fifth.example.com"))
}
}

View File

@ -0,0 +1,253 @@
package com.bitchat.android.nostr
import java.io.File
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
/**
* Pins the directory validation ported from GeoRelayDirectory.validatedEntries and
* validatedDirectoryAddress. The rules are part of the cross-client contract: a file
* one platform accepts and the other rejects splits the two relay selections at every
* geohash at once, which is a larger divergence than the two-file split #914 closed.
* Every rejection here is a whole-file rejection; the caller keeps its previous copy.
*/
class RelayDirectoryValidationTest {
private val header = "Relay URL,Latitude,Longitude\n"
private fun validate(csv: String, minimumEntries: Int = 1, baseline: Set<RelayDirectory.RelayInfo>? = null) =
RelayDirectory.validatedEntries(csv.toByteArray(), minimumEntries, baseline)
// MARK: file-level rules
@Test
fun `an empty file is rejected`() {
assertNull(RelayDirectory.validatedEntries(ByteArray(0), minimumEntries = 1))
}
@Test
fun `a file with only a header is rejected`() {
assertNull(validate(header))
}
@Test
fun `both header forms ios accepts parse here`() {
assertNotNull(validate("Relay URL,Latitude,Longitude\nrelay-a.example,1.0,2.0\n"))
assertNotNull(validate("relay url,lat,lon\nrelay-a.example,1.0,2.0\n"))
}
@Test
fun `a header ios rejects rejects the file here`() {
assertNull(validate("Relay URL,Lat,Long\nrelay-a.example,1.0,2.0\n"))
assertNull(validate("url,latitude,longitude\nrelay-a.example,1.0,2.0\n"))
}
@Test
fun `a single byte order mark is stripped the way ios strips it`() {
// Foundation's UTF-8 decode removes one leading BOM before iOS's BOM
// check runs; only a doubled BOM reaches the check. Verified against the
// real Swift code over these exact bytes.
assertNotNull(validate("" + header + "relay-a.example,1.0,2.0\n"))
assertNull(validate("" + header + "relay-a.example,1.0,2.0\n"))
}
@Test
fun `percent escapes decode into the host key the way ios decodes them`() {
assertEquals("relay.example", RelayDirectory.validatedDirectoryAddress("re%6Cay.example"))
assertEquals("a.b.example", RelayDirectory.validatedDirectoryAddress("a%2Eb.example"))
assertNull("invalid escape", RelayDirectory.validatedDirectoryAddress("wss://h%GGx.example"))
assertNull("truncated escape", RelayDirectory.validatedDirectoryAddress("wss://hx.example%2"))
assertNull("escape decoding to a query", RelayDirectory.validatedDirectoryAddress("wss://h%3Fx.example"))
assertNull("escape decoding to non-ascii", RelayDirectory.validatedDirectoryAddress("re%C3%A9seau.example"))
// iOS decodes after structural parsing, so a decoded colon stays in the
// host and fails the label screen; decoded first it would become a port.
assertNull("escape decoding to a colon", RelayDirectory.validatedDirectoryAddress("relay-a.example%3A8443"))
}
@Test
fun `hex coordinates parse the way swift parses them`() {
val plain = validate(header + "relay-a.example,0x10,2.0\n")
assertNotNull(plain)
assertEquals(16.0, plain!![0].latitude, 0.0)
val hexDigitTail = validate(header + "relay-a.example,0x1d,2.0\n")
assertNotNull("d is a hex digit, not a suffix", hexDigitTail)
assertEquals(29.0, hexDigitTail!![0].latitude, 0.0)
}
@Test
fun `signed zero is one coordinate and the last row's bits are kept`() {
val entries = validate(header + "z.example,0.0,1.0\n" + "z.example,-0.0,1.0\n")
assertNotNull("swift's == treats -0.0 and 0.0 as the same coordinate", entries)
assertEquals(1, entries!!.size)
assertEquals((-0.0).toRawBits(), entries[0].latitude.toRawBits())
}
@Test
fun `every punycode label is rejected`() {
// iOS IDNA-decodes xn-- labels: valid punycode becomes non-ASCII and is
// rejected, and most invalid forms fail its parse, both pinned in the
// battery. Foundation lets a few exotic invalid forms through
// literally; this port rejects every xn-- label instead, stricter in
// the safe direction, measured by the fuzz round.
assertNull(RelayDirectory.validatedDirectoryAddress("xn--bcher-kva.example"))
assertNull(RelayDirectory.validatedDirectoryAddress("xn--x.example"))
}
@Test
fun `hosts uri refuses but ios accepts are salvaged by the fallback`() {
// java.net.URI follows RFC 2396 and returns no host when the final
// label starts with a digit, or when a trailing colon carries no port;
// iOS's RFC 3986 parser accepts both. The plain-authority fallback
// covers exactly these shapes.
assertEquals("b.08relay", RelayDirectory.validatedDirectoryAddress("https://b.08relay"))
assertEquals("relay.example.1", RelayDirectory.validatedDirectoryAddress("relay.example.1:"))
assertEquals("d.7ex:8443", RelayDirectory.validatedDirectoryAddress("wss://d.7ex:8443"))
}
@Test
fun `invalid utf8 rejects the file`() {
assertNull(RelayDirectory.validatedEntries(byteArrayOf(0xFF.toByte(), 0xFE.toByte(), 0x41), minimumEntries = 1))
}
@Test
fun `a file over the byte cap is rejected`() {
val oversized = ByteArray(RelayDirectory.MAX_DIRECTORY_BYTES + 1) { 'a'.code.toByte() }
assertNull(RelayDirectory.validatedEntries(oversized, minimumEntries = 1))
}
@Test
fun `more rows than the row cap rejects the file`() {
val rows = buildString {
append(header)
repeat(RelayDirectory.MAX_DIRECTORY_ROWS + 1) { append("relay-a.example,1.0,2.0\n") }
}
assertNull(validate(rows))
}
// MARK: row-level rules, each rejecting the whole file
@Test
fun `one malformed row rejects the whole file`() {
val csv = header +
"relay-a.example,1.0,2.0\n" +
"relay-b.example,2.0\n" +
"relay-c.example,3.0,4.0\n"
assertNull(validate(csv))
}
@Test
fun `an out of range coordinate rejects the whole file`() {
assertNull(validate(header + "relay-a.example,91.0,2.0\n"))
assertNull(validate(header + "relay-a.example,1.0,181.0\n"))
assertNull(validate(header + "relay-a.example,abc,2.0\n"))
}
@Test
fun `a coordinate spelling only java parses rejects the file`() {
// Double.parseDouble takes "1.5f"; Swift's Double(String) does not. Both
// platforms must refuse the row, or one keeps a file the other drops.
assertNull(validate(header + "relay-a.example,1.5f,2.0\n"))
assertNull(validate(header + "relay-a.example,1.0,2.0d\n"))
}
@Test
fun `a row with a rejected host rejects the whole file`() {
val csv = header +
"relay-a.example,1.0,2.0\n" +
"localhost,1.0,2.0\n"
assertNull(validate(csv))
}
@Test
fun `hosts ios rejects are rejected here`() {
val rejected = mapOf(
"wss://relay.example.com/path" to "path beyond /",
"wss://user@relay.example.com" to "userinfo",
"wss://relay.example.com?x=1" to "query",
"wss://relay.example.com#frag" to "fragment",
"ws://relay.example.com" to "scheme other than wss or https",
"localhost" to "localhost",
"node.local" to ".local",
"svc.internal" to ".internal",
"a.localhost" to ".localhost",
"singlelabel" to "single label",
"192.0.2.7" to "all-numeric labels",
"réseau.example" to "non-ascii",
"-bad.example" to "label starting with hyphen",
"bad-.example" to "label ending with hyphen",
"${"a".repeat(64)}.example" to "label over 63 chars",
"${(1..4).joinToString(".") { "a".repeat(63) }}.ex" to "host over 253 chars",
"relay.example.com:0" to "port below 1",
"relay.example.com:70000" to "port above 65535",
"relay.example." to "trailing dot"
)
for ((raw, reason) in rejected) {
assertNull(reason, RelayDirectory.validatedDirectoryAddress(raw))
}
}
// MARK: floors and the hijack guard
@Test
fun `a remote file below the entry floor is rejected`() {
val csv = header + "relay-a.example,1.0,2.0\n"
assertNull(validate(csv, minimumEntries = RelayDirectory.MIN_REMOTE_ENTRIES))
assertNotNull(validate(csv, minimumEntries = 1))
}
@Test
fun `a download keeping less than half of the known entries is rejected`() {
val baseline = setOf(
RelayDirectory.RelayInfo("wss://base-a.example", 1.0, 1.0),
RelayDirectory.RelayInfo("wss://base-b.example", 2.0, 2.0),
RelayDirectory.RelayInfo("wss://base-c.example", 3.0, 3.0),
RelayDirectory.RelayInfo("wss://base-d.example", 4.0, 4.0)
)
val keepsTwo = header +
"base-a.example,1.0,1.0\n" +
"base-b.example,2.0,2.0\n" +
"fresh-a.example,5.0,5.0\n"
assertNotNull(validate(keepsTwo, baseline = baseline))
val keepsOne = header +
"base-a.example,1.0,1.0\n" +
"fresh-a.example,5.0,5.0\n" +
"fresh-b.example,6.0,6.0\n"
assertNull(validate(keepsOne, baseline = baseline))
}
// MARK: the shipped asset
@Test
fun `the bundled asset passes the same validation a download must pass`() {
// fetch-georelays.yml rewrites the bundled asset every week, so this
// test checks validity, not contents: whatever the job ships must pass
// the same validation a download must pass. The exact-count check
// lives on the fixed snapshot below.
val asset = listOf(
File("src/main/assets/nostr_relays.csv"),
File("app/src/main/assets/nostr_relays.csv")
).firstOrNull { it.isFile } ?: error("bundled relay asset not found")
val entries = RelayDirectory.validatedEntries(
asset.readBytes(),
minimumEntries = RelayDirectory.MIN_REMOTE_ENTRIES
)
assertNotNull("the bundled asset must pass validation", entries)
}
@Test
fun `a fixed snapshot of the directory yields the exact entry count`() {
// A copy of online_relays_gps.csv taken 2026-08-30, 441 rows collapsing
// to 326 entries. This file never changes, so a different count here is
// a change in the validator, never a change in the data.
val snapshot = listOf(
File("src/test/resources/nostr_relays_snapshot.csv"),
File("app/src/test/resources/nostr_relays_snapshot.csv")
).firstOrNull { it.isFile } ?: error("relay snapshot fixture not found")
val entries = RelayDirectory.validatedEntries(snapshot.readBytes(), minimumEntries = 1)
assertNotNull(entries)
assertEquals(326, entries!!.size)
}
}

View File

@ -0,0 +1,442 @@
Relay URL,Latitude,Longitude
bitchat.nostr1.com,40.7057,-74.0136
relay.fundstr.me,42.3601,-71.0589
nostr.2b9t.xyz,34.0549,-118.243
armada.sharegap.net,43.6532,-79.3832
nostr.chaima.info,51.5072,-0.127586
nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832
ribo.eu.nostria.app:443,43.6532,-79.3832
relay.lightning.pub,39.0438,-77.4874
relay.nostu.be,40.4167,-3.70329
nostr.whitenode45.ddns.net,40.55,-74.4758
nostr.carroarmato0.be:443,50.914,3.21378
cdn.satellite.earth,40.8302,-74.1299
relay2.veganostr.com,60.1699,24.9384
relay.layer.systems:443,49.0291,8.35695
relay0.gfcom.info,13.7653,100.647
relay.mmwaves.de:443,48.8575,2.35138
offchain.pub,39.1585,-94.5728
bcast.girino.org,43.6532,-79.3832
staging.yabu.me,35.6092,139.73
nostr.overpay.com,29.7449,-95.5343
bridge.tagomago.me,42.3601,-71.0589
nostr-01.yakihonne.com,1.32123,103.695
strfry.bonsai.com,39.0438,-77.4874
relay.sharegap.net,43.6532,-79.3832
nostr.islandarea.net,35.4669,-97.6473
dm-test-strfry-generic.samt.st,43.6532,-79.3832
treuzkas.branruz.com,48.8575,2.35138
relay-rpi.edufeed.org:443,49.4521,11.0767
vault.iris.to:443,43.6532,-79.3832
node.kommonzenze.de,49.4521,11.0767
nostr.thalheim.io:443,60.1699,24.9384
soloco.nl,43.6532,-79.3832
strfry.shock.network,39.0438,-77.4874
nostr-relay.zimage.com,34.0549,-118.243
public.crostr.com:443,43.6532,-79.3832
nostr.sathoarder.com:443,48.5734,7.75211
relay.angor.io,48.1046,11.6002
relay.wellorder.net,45.5201,-122.99
relay.mwaters.net,50.9871,2.12554
relay.staging.commonshub.brussels,49.4543,11.0746
nostr-verified.wellorder.net,45.5201,-122.99
nostr-pub.wellorder.net,45.5201,-122.99
nostr-2.21crypto.ch,47.5356,8.73209
relay.kaleidoswap.com,50.8476,4.35717
relay.libernet.app:443,43.6532,-79.3832
relay.homeinhk.xyz,35.694,139.754
relay.manneken.brussels,49.4543,11.0746
nostr.spicyz.io:443,43.6532,-79.3832
relay.lanacoin-eternity.com:443,40.8302,-74.1299
ribo.us.nostria.app:443,43.6532,-79.3832
relay.loveisbitcoin.com,43.6532,-79.3832
relay.angor.io:443,48.1046,11.6002
relay02.lnfi.network,35.6764,139.65
relay.cosmicbolt.net:443,37.3986,-121.964
nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397
nrs-01.darkcloudarcade.com,39.0997,-94.5786
relay.endfiat.money:443,59.3327,18.0656
relay.paulstephenborile.com,49.4543,11.0746
rele.speyhard.fi,51.5072,-0.127586
relay.froth.zone,60.1699,24.9384
relay.nostr.blockhenge.com,39.0438,-77.4874
nrl.ceskar.xyz,50.5145,16.0119
rilo.nostria.app,43.6532,-79.3832
nostr.overmind.lol:443,43.6532,-79.3832
nostr.snowbla.de:443,50.4754,12.3683
nostrrelay.taylorperron.com,45.5029,-73.5723
chorus.pjv.me,45.5201,-122.99
relay.nostr.place,43.6532,-79.3832
bucket.coracle.social,37.7775,-122.397
nostr.girino.org:443,43.6532,-79.3832
relay.aarpia.com,37.3986,-121.964
nostr.thalheim.io,60.1699,24.9384
ec2.f7z.io,60.1699,24.9384
relay.trotters.cc,43.6532,-79.3832
relay.mccormick.cx:443,52.3563,4.95714
relay.momostr.pink,43.6532,-79.3832
relay.nostr.net,43.6532,-79.3832
conduitl2.fly.dev,37.7648,-122.432
chat-relay.zap-work.com,43.6532,-79.3832
relay.ditto.pub,43.6532,-79.3832
relay.veganostr.com,60.1699,24.9384
relay.minibolt.info:443,43.6532,-79.3832
adre.su,59.9311,30.3609
bitcoinostr.duckdns.org,41.1976,1.11167
nostr.computingcache.com:443,34.0356,-118.442
relay-fra.zombi.cloudrodion.com,48.8566,2.35222
nostr.hekster.org:443,37.3986,-121.964
nostr.88mph.life,52.1941,-2.21905
wot.dergigi.com,64.1476,-21.9392
nostr.planix.org,43.6532,-79.3832
relay.satsmarkt.club,52.6907,4.8181
nostrcity-club.fly.dev:443,37.7648,-122.432
aeon.libretechsystems.xyz,55.486,9.86577
testnet.samt.st,43.6532,-79.3832
nostr.data.haus,50.4754,12.3683
wot.sudocarlos.com,43.6532,-79.3832
relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222
shu01.shugur.net,21.4902,39.2246
relay.gulugulu.moe:443,43.6532,-79.3832
relay2.angor.io:443,48.1046,11.6002
relay.libernet.app,43.6532,-79.3832
directories-safe-motherboard-recipients.trycloudflare.com,43.6532,-79.3832
wot.nostr.party,36.1659,-86.7844
relay.zone667.com,60.1699,24.9384
nostr.wild-vibes.ts.net,48.8566,2.35222
relay.nostr.com,50.1109,8.68213
nostr.iskarion.ddns.net,43.3076,-2.95421
relay-dev.satlantis.io,39.0438,-77.4874
relay.sovereignresonance.org,48.9006,2.25929
relay.nostrian-conquest.com,41.223,-111.974
relay.aidatanorge.no,43.6532,-79.3832
strfry.apps3.slidestr.net,40.4167,-3.70329
relay.klabo.world,47.2343,-119.853
nostr.data.haus:443,50.4754,12.3683
testr.nymble.world,40.8054,-74.0241
relay.inforsupports.com,43.6532,-79.3832
relay.nostrmap.net:443,60.1699,24.9384
nostr.stakey.net:443,52.3676,4.90414
dev-relay.nostreon.com,60.1699,24.9384
nostr.islandarea.net:443,35.4669,-97.6473
nostr.rtvslawenia.com,49.4543,11.0746
relay.bowlafterbowl.com,32.9483,-96.7299
nostr.quali.chat:443,60.1699,24.9384
relay.plebeian.market,50.1109,8.68213
relay-rpi.edufeed.org,49.4521,11.0767
r.0kb.io,32.789,-96.7989
nostr.notribe.net:443,40.8302,-74.1299
relay.getsafebox.app:443,43.6532,-79.3832
nostr.dlcdevkit.com:443,40.0992,-83.1141
nostrelites.org,34.9582,-81.9907
nostr.hoppe-relay.it.com,42.8864,-78.8784
nostr.thebiglake.org,32.71,-96.6745
nostr-kyomu-haskell.onrender.com,37.7775,-122.397
relay.nostriot.com,41.5695,-83.9786
nostr.christiansass.de,51.7634,7.8887
relay.btcforplebs.com,43.6532,-79.3832
nostr.tagomago.me,42.3601,-71.0589
relayone.geektank.ai,39.0997,-94.5786
relay.dreamith.to:443,43.6532,-79.3832
nostr.liberty.fans,36.8767,-89.5879
wot.makenomistakes.ca,43.7064,-79.3986
relay.goodmorningbitcoin.com,43.6532,-79.3832
relay.layer.systems,49.0291,8.35695
relay.paulstephenborile.com:443,49.4543,11.0746
relay.ohstr.com,43.6532,-79.3832
nostr-relay.xbytez.io:443,50.6924,3.20113
nostr.ac,38.958,-77.3592
ribo.us.nostria.app,43.6532,-79.3832
nostr.21crypto.ch,47.5356,8.73209
relay.chorus.community:443,48.5333,10.7
relay.cypherflow.ai,48.8575,2.35138
relay.agorist.space:443,52.3734,4.89406
relay.nostrian-conquest.com:443,41.223,-111.974
relay.keykeeper.world,40.7824,-74.0711
relay.getvia.xyz,60.1699,24.9384
relay.nuts.cash,52.3676,4.90414
kotukonostr.onrender.com,37.7775,-122.397
relay.minibolt.info,43.6532,-79.3832
relay.dwadziesciajeden.pl,52.2297,21.0122
relay.fountain.fm:443,43.6532,-79.3832
relay.fountain.fm,43.6532,-79.3832
nostr-02.uid.ovh,50.9871,2.12554
relay.lanavault.space:443,60.1699,24.9384
nostr.carroarmato0.be,50.914,3.21378
nexus.libernet.app:443,43.6532,-79.3832
relay.artio.inf.unibe.ch,46.9501,7.43678
blossom.gnostr.cloud,43.6532,-79.3832
relay.binaryrobot.com,43.6532,-79.3832
relay.earthly.city,34.1749,-118.54
nostr.hifish.org,47.4244,8.57658
offchain.pub:443,39.1585,-94.5728
relay.bullishbounty.com:443,43.6532,-79.3832
strfry.openhoofd.nl:443,51.5717,3.70417
cs-relay.nostrdev.com:443,50.4754,12.3683
strfry.ymir.cloud,43.6532,-79.3832
nostrbtc.com,43.6532,-79.3832
relay.directsponsor.net,42.8864,-78.8784
nostr2.girino.org,43.6532,-79.3832
relay.sigit.io:443,50.4754,12.3683
relay.getsafebox.app,43.6532,-79.3832
antiprimal.net,43.6532,-79.3832
nostr.sathoarder.com,48.5734,7.75211
inbox.scuba323.com,40.8218,-74.45
nrs-01.darkcloudarcade.com:443,39.0997,-94.5786
nostr.tac.lol,47.4748,-122.273
nostr.davenov.com,50.1109,8.68213
relay.trotters.cc:443,43.6532,-79.3832
nostr.plantroon.com:443,50.1013,8.62643
relay.nostreon.com,60.1699,24.9384
nostr.easycryptosend.it,43.6532,-79.3832
nostr-01.yakihonne.com:443,1.32123,103.695
relay-testnet.k8s.layer3.news,37.3387,-121.885
nostr.purpura.cloud,43.6532,-79.3832
insta-relay.apps3.slidestr.net,40.4167,-3.70329
nostr.mifen.me,43.6532,-79.3832
testnet-relay.samt.st:443,40.8302,-74.1299
nostr.2b9t.xyz:443,34.0549,-118.243
relay.wavlake.com:443,41.2619,-95.8608
relay.wisp.talk:443,49.4543,11.0746
relay-dev.satlantis.io:443,39.0438,-77.4874
relay.satlantis.io,39.0438,-77.4874
relay.staging.plebeian.market,51.5072,-0.127586
relay.openfarmtools.org,60.1699,24.9384
relay.nostrhub.fr,48.1045,11.6004
nostr-relay.xbytez.io,50.6924,3.20113
relay.binaryrobot.com:443,43.6532,-79.3832
relay.samt.st,40.8302,-74.1299
relay.illuminodes.com,43.6532,-79.3832
relay.liberbitworld.org,43.6532,-79.3832
relay.olas.app:443,60.1699,24.9384
no.str.cr,8.96171,-83.5246
dm-test-strfry-discovery.samt.st,43.6532,-79.3832
wot.rejecttheframe.xyz,43.6532,-79.3832
relay.nostriot.com:443,41.5695,-83.9786
nostr.plantroon.com,50.1013,8.62643
nostr-01.uid.ovh,50.9871,2.12554
relay.openresist.com:443,43.6532,-79.3832
nostr.overmind.lol,43.6532,-79.3832
relay.internationalright-wing.org,-22.5022,-48.7114
nostr.myshosholoza.co.za:443,52.3676,4.90414
nostr.pbfs.io:443,50.4754,12.3683
21milionidinostr.duckdns.org,41.8967,12.4822
nostr.4rs.nl,49.0291,8.35696
relay.lanavault.space,60.1699,24.9384
relay.mostr.pub,43.6532,-79.3832
relay.nostar.org,43.6532,-79.3832
nostr.mom,50.4754,12.3683
relay.decentralia.fr,48.122,11.589
relay.agentry.com,42.8864,-78.8784
relay2.angor.io,48.1046,11.6002
slick.mjex.me,39.0418,-77.4744
relay-us.zombi.cloudrodion.com,40.7862,-74.0743
relay.vrtmrz.net:443,43.6532,-79.3832
relay.beginningend.com,35.2227,-97.4786
chat-relay.zap-work.com:443,43.6532,-79.3832
relay.underorion.se,50.1109,8.68213
relay.mitchelltribe.com,39.0438,-77.4874
relay.qstr.app,51.5072,-0.127586
relay.cyberguy.fyi,52.6907,4.8181
strfry.bonsai.com:443,39.0438,-77.4874
relayone.soundhsa.com:443,39.0997,-94.5786
relay.sigit.io,50.4754,12.3683
relay.npubhaus.com,43.6532,-79.3832
relayrs.notoshi.win,43.6532,-79.3832
relay.mitchelltribe.com:443,39.0438,-77.4874
relay.44billion.net,43.6532,-79.3832
reraw.pbla2fish.cc,43.6532,-79.3832
articles.layer3.news:443,37.3387,-121.885
nostr.sovereignservices.xyz,43.6532,-79.3832
relay.nostx.io,43.6532,-79.3832
nostr-relay.amethyst.name,39.0067,-77.4291
0x-nostr-relay.fly.dev,37.7648,-122.432
relay.ohstr.com:443,43.6532,-79.3832
00f2e774.relay.dev.thunderegg.us,39.0438,-77.4874
nostr-relay.cbrx.io,43.6532,-79.3832
relay.wavlake.com,41.2619,-95.8608
purplerelay.com:443,43.6532,-79.3832
nostr-pr02.redscrypt.org,52.3676,4.90414
fanfares.nostr1.com:443,40.7057,-74.0136
kasztanowa.bieda.it,43.6532,-79.3832
relay.flashapp.me,43.6548,-79.3885
relay.typedcypher.com,51.5072,-0.127586
nostr.bond,50.1109,8.68213
nostr.azzamo.net,52.2633,21.0283
nexus.libernet.app,43.6532,-79.3832
relay.cosmicbolt.net,37.3986,-121.964
schnorr.me,43.6532,-79.3832
relay.mostro.network:443,40.8302,-74.1299
relay-arg.zombi.cloudrodion.com,1.35208,103.82
relay.chorus.community,48.5333,10.7
blossom.gnostr.cloud:443,43.6532,-79.3832
syb.lol:443,34.0549,-118.243
relay.dyne.org,49.0291,8.35705
btc.klendazu.com,41.2861,1.24993
wot.nostr.place,43.6532,-79.3832
relay.openresist.com,43.6532,-79.3832
rilo.nostria.app:443,43.6532,-79.3832
no.str.cr:443,8.96171,-83.5246
relay.mostr.pub:443,43.6532,-79.3832
relay.edufeed.org:443,49.4521,11.0767
nostr.debate.report,50.1109,8.68213
relay.satmaxt.xyz:443,43.6532,-79.3832
relay.artx.market:443,43.6548,-79.3885
relay-dev.gulugulu.moe,43.6532,-79.3832
relay.novospes.com,43.6532,-79.3832
relay.nostr-check.me,43.6532,-79.3832
nostr.computingcache.com,34.0356,-118.442
nostr.oxtr.dev,50.4754,12.3683
relay.fckstate.net,59.3293,18.0686
relay.vrtmrz.net,43.6532,-79.3832
relay.bornheimer.app,51.5072,-0.127586
relay.guggero.org,46.5971,9.59652
relay01.lnfi.network,35.6764,139.65
wot.shaving.kiwi,43.6532,-79.3832
nostr.twinkle.lol,51.902,7.6657
relay.edufeed.org,49.4521,11.0767
relay.lanacoin-eternity.com,40.8302,-74.1299
relay.satmaxt.xyz,43.6532,-79.3832
nostr.hifish.org:443,47.4244,8.57658
relay.cypherflow.ai:443,48.8575,2.35138
infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832
nostr.na.social:443,43.6532,-79.3832
nostr.rtvslawenia.com:443,49.4543,11.0746
relay.mypathtofire.de,42.8864,-78.8784
public.crostr.com,43.6532,-79.3832
relay.olas.app,60.1699,24.9384
relay.agora.social,50.7383,15.0648
ribo.nostria.app,43.6532,-79.3832
relay.lab.rytswd.com,49.4543,11.0746
relay.ditto.pub:443,43.6532,-79.3832
porchlight.social,43.6532,-79.3832
nostr.notribe.net,40.8302,-74.1299
relay.endfiat.money,59.3327,18.0656
nostr.myshosholoza.co.za,52.3676,4.90414
relay.nearhood.co.uk,51.5134,-0.0890675
relay.degmods.com,50.4754,12.3683
nostr.novacisko.cz,52.2026,20.9397
prl.plus,55.7628,37.5983
bruh.samt.st,43.6532,-79.3832
strfry.openhoofd.nl,51.5717,3.70417
nostr.spicyz.io,43.6532,-79.3832
nostr.na.social,43.6532,-79.3832
nip85.nosfabrica.com,39.0997,-94.5786
premium.primal.net,43.6532,-79.3832
fanfares.nostr1.com,40.7057,-74.0136
relay.scuba323.com,40.8218,-74.45
nostr2.girino.org:443,43.6532,-79.3832
relay.mmwaves.de,48.8575,2.35138
nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874
strfry.shock.network:443,39.0438,-77.4874
nostr.snowbla.de,50.4754,12.3683
nostr.spaceshell.xyz,43.6532,-79.3832
nostr.quali.chat,60.1699,24.9384
wot.utxo.one,43.6532,-79.3832
relay.mccormick.cx,52.3563,4.95714
mostro-p2p.tech,50.1109,8.68213
basspistol.org,49.0291,8.35696
ribo.nostria.app:443,43.6532,-79.3832
chorus.mikedilger.com:444,-36.8906,174.794
nostr.oxtr.dev:443,50.4754,12.3683
nostr.nodesmap.com,59.3327,18.0656
offchain.bostr.online,43.6532,-79.3832
purplerelay.com,43.6532,-79.3832
relayrs.notoshi.win:443,43.6532,-79.3832
relay.wavefunc.live,41.8781,-87.6298
relay.dreamith.to,43.6532,-79.3832
bendernostur.duckdns.org:8443,50.1109,8.68213
relay.nmail.li,50.9871,2.12554
nostr-relay.corb.net,39.6478,-104.988
relay.staging.plebeian.market:443,51.5072,-0.127586
spamspamspamspam.rest,43.6532,-79.3832
relay1.gfcom.info,13.9215,100.538
schnorr.me:443,43.6532,-79.3832
relay.lab.rytswd.com:443,49.4543,11.0746
nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874
dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832
relay.nostrmap.net,60.1699,24.9384
nostr.relay.hedwig.sh,60.1699,24.9384
relay.veganostr.com:443,60.1699,24.9384
relay.wavefunc.live:443,41.8781,-87.6298
nostr.mikoshi.de,52.52,13.405
syb.lol,34.0549,-118.243
relay1.nostrchat.io,60.1699,24.9384
nostr.wecsats.io:443,43.6532,-79.3832
nostr.chaima.info:443,51.5072,-0.127586
nostr.azzamo.net:443,52.2633,21.0283
relay-can.zombi.cloudrodion.com,43.6532,-79.3832
nostr.unkn0wn.world,46.8499,9.53287
relayone.soundhsa.com,39.0997,-94.5786
x.kojira.io,43.6532,-79.3832
dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832
nostrelay.circum.space,52.6907,4.8181
relay.primal.net,43.6532,-79.3832
nostr.girino.org,43.6532,-79.3832
nostr.pbfs.io,50.4754,12.3683
relay.kalcafe.xyz,37.3986,-121.964
relay.gulugulu.moe,43.6532,-79.3832
top.testrelay.top,43.6532,-79.3832
relay.kilombino.com,43.6532,-79.3832
nos.lol:443,50.4754,12.3683
nos.lol,50.4754,12.3683
relay.nostr.place:443,43.6532,-79.3832
cache.trustr.ing,43.6548,-79.3885
relay.internationalright-wing.org:443,-22.5022,-48.7114
relay.laantungir.net,-19.4692,-42.5315
relay.lightning.pub:443,39.0438,-77.4874
nostr.stakey.net,52.3676,4.90414
articles.layer3.news,37.3387,-121.885
relay.wisp.talk,49.4543,11.0746
relay.pyramid.li,47.4093,8.46503
relay.typedcypher.com:443,51.5072,-0.127586
dev.relay.stream,43.6532,-79.3832
relay.bullishbounty.com,43.6532,-79.3832
nostr.mom:443,50.4754,12.3683
relay.plebeian.market:443,50.1109,8.68213
nostr.hekster.org,37.3986,-121.964
nostrcity-club.fly.dev,37.7648,-122.432
nostr.vulpem.com,49.4543,11.0746
relay-dev.gulugulu.moe:443,43.6532,-79.3832
weboftrust.libretechsystems.xyz,55.4724,9.87335
nostr-relay.corb.net:443,39.6478,-104.988
wheat.happytavern.co,43.6532,-79.3832
relay.mappingbitcoin.com,43.6532,-79.3832
testnet-relay.samt.st,40.8302,-74.1299
relay.bitmacro.cloud,43.6532,-79.3832
dev.relay.edufeed.org,49.4521,11.0767
myvoiceourstory.org,37.3598,-121.981
relay.stickeroo.is-cool.dev,37.3387,-121.885
relay.agorist.space,52.3734,4.89406
freelay.sovbit.host,60.1699,24.9384
nostr-dev.wellorder.net,45.5201,-122.99
nostr.middling.mydns.jp,35.8099,140.12
cs-relay.nostrdev.com,50.4754,12.3683
x.kojira.io:443,43.6532,-79.3832
nostrelay.circum.space:443,52.6907,4.8181
nostr.janx.com,43.6532,-79.3832
relay.mrmave.work,43.6532,-79.3832
espelho.girino.org,43.6532,-79.3832
hol.is,43.6532,-79.3832
ribo.eu.nostria.app,43.6532,-79.3832
nostr.yutakobayashi.com,43.6532,-79.3832
relay.mostro.network,40.8302,-74.1299
communities.nos.social,40.8302,-74.1299
relay.solife.me,43.6532,-79.3832
yabu.me,35.6092,139.73
relay.islandbitcoin.com,12.8498,77.6545
nostr.wecsats.io,43.6532,-79.3832
nostr.tac.lol:443,47.4748,-122.273
relay.arx-ccn.com,50.4754,12.3683
nostrride.io,37.3986,-121.964
r.0kb.io:443,32.789,-96.7989
herbstmeister.com,34.0549,-118.243
relay.artx.market,43.6548,-79.3885
vault.iris.to,43.6532,-79.3832
relay.ru.ac.th,13.7607,100.627
temp.iris.to,43.6532,-79.3832
social.amanah.eblessing.co,48.1046,11.6002
nostr-relay.nextblockvending.com,47.2343,-119.853
wot.codingarena.top,50.4754,12.3683
relay.sincensura.org,43.6532,-79.3832
nostr.dlcdevkit.com,40.0992,-83.1141
1 Relay URL Latitude Longitude
2 bitchat.nostr1.com 40.7057 -74.0136
3 relay.fundstr.me 42.3601 -71.0589
4 nostr.2b9t.xyz 34.0549 -118.243
5 armada.sharegap.net 43.6532 -79.3832
6 nostr.chaima.info 51.5072 -0.127586
7 nosflare-leefcore.leefcore.workers.dev 43.6532 -79.3832
8 ribo.eu.nostria.app:443 43.6532 -79.3832
9 relay.lightning.pub 39.0438 -77.4874
10 relay.nostu.be 40.4167 -3.70329
11 nostr.whitenode45.ddns.net 40.55 -74.4758
12 nostr.carroarmato0.be:443 50.914 3.21378
13 cdn.satellite.earth 40.8302 -74.1299
14 relay2.veganostr.com 60.1699 24.9384
15 relay.layer.systems:443 49.0291 8.35695
16 relay0.gfcom.info 13.7653 100.647
17 relay.mmwaves.de:443 48.8575 2.35138
18 offchain.pub 39.1585 -94.5728
19 bcast.girino.org 43.6532 -79.3832
20 staging.yabu.me 35.6092 139.73
21 nostr.overpay.com 29.7449 -95.5343
22 bridge.tagomago.me 42.3601 -71.0589
23 nostr-01.yakihonne.com 1.32123 103.695
24 strfry.bonsai.com 39.0438 -77.4874
25 relay.sharegap.net 43.6532 -79.3832
26 nostr.islandarea.net 35.4669 -97.6473
27 dm-test-strfry-generic.samt.st 43.6532 -79.3832
28 treuzkas.branruz.com 48.8575 2.35138
29 relay-rpi.edufeed.org:443 49.4521 11.0767
30 vault.iris.to:443 43.6532 -79.3832
31 node.kommonzenze.de 49.4521 11.0767
32 nostr.thalheim.io:443 60.1699 24.9384
33 soloco.nl 43.6532 -79.3832
34 strfry.shock.network 39.0438 -77.4874
35 nostr-relay.zimage.com 34.0549 -118.243
36 public.crostr.com:443 43.6532 -79.3832
37 nostr.sathoarder.com:443 48.5734 7.75211
38 relay.angor.io 48.1046 11.6002
39 relay.wellorder.net 45.5201 -122.99
40 relay.mwaters.net 50.9871 2.12554
41 relay.staging.commonshub.brussels 49.4543 11.0746
42 nostr-verified.wellorder.net 45.5201 -122.99
43 nostr-pub.wellorder.net 45.5201 -122.99
44 nostr-2.21crypto.ch 47.5356 8.73209
45 relay.kaleidoswap.com 50.8476 4.35717
46 relay.libernet.app:443 43.6532 -79.3832
47 relay.homeinhk.xyz 35.694 139.754
48 relay.manneken.brussels 49.4543 11.0746
49 nostr.spicyz.io:443 43.6532 -79.3832
50 relay.lanacoin-eternity.com:443 40.8302 -74.1299
51 ribo.us.nostria.app:443 43.6532 -79.3832
52 relay.loveisbitcoin.com 43.6532 -79.3832
53 relay.angor.io:443 48.1046 11.6002
54 relay02.lnfi.network 35.6764 139.65
55 relay.cosmicbolt.net:443 37.3986 -121.964
56 nostr-rs-relay-qj1h.onrender.com 37.7775 -122.397
57 nrs-01.darkcloudarcade.com 39.0997 -94.5786
58 relay.endfiat.money:443 59.3327 18.0656
59 relay.paulstephenborile.com 49.4543 11.0746
60 rele.speyhard.fi 51.5072 -0.127586
61 relay.froth.zone 60.1699 24.9384
62 relay.nostr.blockhenge.com 39.0438 -77.4874
63 nrl.ceskar.xyz 50.5145 16.0119
64 rilo.nostria.app 43.6532 -79.3832
65 nostr.overmind.lol:443 43.6532 -79.3832
66 nostr.snowbla.de:443 50.4754 12.3683
67 nostrrelay.taylorperron.com 45.5029 -73.5723
68 chorus.pjv.me 45.5201 -122.99
69 relay.nostr.place 43.6532 -79.3832
70 bucket.coracle.social 37.7775 -122.397
71 nostr.girino.org:443 43.6532 -79.3832
72 relay.aarpia.com 37.3986 -121.964
73 nostr.thalheim.io 60.1699 24.9384
74 ec2.f7z.io 60.1699 24.9384
75 relay.trotters.cc 43.6532 -79.3832
76 relay.mccormick.cx:443 52.3563 4.95714
77 relay.momostr.pink 43.6532 -79.3832
78 relay.nostr.net 43.6532 -79.3832
79 conduitl2.fly.dev 37.7648 -122.432
80 chat-relay.zap-work.com 43.6532 -79.3832
81 relay.ditto.pub 43.6532 -79.3832
82 relay.veganostr.com 60.1699 24.9384
83 relay.minibolt.info:443 43.6532 -79.3832
84 adre.su 59.9311 30.3609
85 bitcoinostr.duckdns.org 41.1976 1.11167
86 nostr.computingcache.com:443 34.0356 -118.442
87 relay-fra.zombi.cloudrodion.com 48.8566 2.35222
88 nostr.hekster.org:443 37.3986 -121.964
89 nostr.88mph.life 52.1941 -2.21905
90 wot.dergigi.com 64.1476 -21.9392
91 nostr.planix.org 43.6532 -79.3832
92 relay.satsmarkt.club 52.6907 4.8181
93 nostrcity-club.fly.dev:443 37.7648 -122.432
94 aeon.libretechsystems.xyz 55.486 9.86577
95 testnet.samt.st 43.6532 -79.3832
96 nostr.data.haus 50.4754 12.3683
97 wot.sudocarlos.com 43.6532 -79.3832
98 relay-fra.zombi.cloudrodion.com:443 48.8566 2.35222
99 shu01.shugur.net 21.4902 39.2246
100 relay.gulugulu.moe:443 43.6532 -79.3832
101 relay2.angor.io:443 48.1046 11.6002
102 relay.libernet.app 43.6532 -79.3832
103 directories-safe-motherboard-recipients.trycloudflare.com 43.6532 -79.3832
104 wot.nostr.party 36.1659 -86.7844
105 relay.zone667.com 60.1699 24.9384
106 nostr.wild-vibes.ts.net 48.8566 2.35222
107 relay.nostr.com 50.1109 8.68213
108 nostr.iskarion.ddns.net 43.3076 -2.95421
109 relay-dev.satlantis.io 39.0438 -77.4874
110 relay.sovereignresonance.org 48.9006 2.25929
111 relay.nostrian-conquest.com 41.223 -111.974
112 relay.aidatanorge.no 43.6532 -79.3832
113 strfry.apps3.slidestr.net 40.4167 -3.70329
114 relay.klabo.world 47.2343 -119.853
115 nostr.data.haus:443 50.4754 12.3683
116 testr.nymble.world 40.8054 -74.0241
117 relay.inforsupports.com 43.6532 -79.3832
118 relay.nostrmap.net:443 60.1699 24.9384
119 nostr.stakey.net:443 52.3676 4.90414
120 dev-relay.nostreon.com 60.1699 24.9384
121 nostr.islandarea.net:443 35.4669 -97.6473
122 nostr.rtvslawenia.com 49.4543 11.0746
123 relay.bowlafterbowl.com 32.9483 -96.7299
124 nostr.quali.chat:443 60.1699 24.9384
125 relay.plebeian.market 50.1109 8.68213
126 relay-rpi.edufeed.org 49.4521 11.0767
127 r.0kb.io 32.789 -96.7989
128 nostr.notribe.net:443 40.8302 -74.1299
129 relay.getsafebox.app:443 43.6532 -79.3832
130 nostr.dlcdevkit.com:443 40.0992 -83.1141
131 nostrelites.org 34.9582 -81.9907
132 nostr.hoppe-relay.it.com 42.8864 -78.8784
133 nostr.thebiglake.org 32.71 -96.6745
134 nostr-kyomu-haskell.onrender.com 37.7775 -122.397
135 relay.nostriot.com 41.5695 -83.9786
136 nostr.christiansass.de 51.7634 7.8887
137 relay.btcforplebs.com 43.6532 -79.3832
138 nostr.tagomago.me 42.3601 -71.0589
139 relayone.geektank.ai 39.0997 -94.5786
140 relay.dreamith.to:443 43.6532 -79.3832
141 nostr.liberty.fans 36.8767 -89.5879
142 wot.makenomistakes.ca 43.7064 -79.3986
143 relay.goodmorningbitcoin.com 43.6532 -79.3832
144 relay.layer.systems 49.0291 8.35695
145 relay.paulstephenborile.com:443 49.4543 11.0746
146 relay.ohstr.com 43.6532 -79.3832
147 nostr-relay.xbytez.io:443 50.6924 3.20113
148 nostr.ac 38.958 -77.3592
149 ribo.us.nostria.app 43.6532 -79.3832
150 nostr.21crypto.ch 47.5356 8.73209
151 relay.chorus.community:443 48.5333 10.7
152 relay.cypherflow.ai 48.8575 2.35138
153 relay.agorist.space:443 52.3734 4.89406
154 relay.nostrian-conquest.com:443 41.223 -111.974
155 relay.keykeeper.world 40.7824 -74.0711
156 relay.getvia.xyz 60.1699 24.9384
157 relay.nuts.cash 52.3676 4.90414
158 kotukonostr.onrender.com 37.7775 -122.397
159 relay.minibolt.info 43.6532 -79.3832
160 relay.dwadziesciajeden.pl 52.2297 21.0122
161 relay.fountain.fm:443 43.6532 -79.3832
162 relay.fountain.fm 43.6532 -79.3832
163 nostr-02.uid.ovh 50.9871 2.12554
164 relay.lanavault.space:443 60.1699 24.9384
165 nostr.carroarmato0.be 50.914 3.21378
166 nexus.libernet.app:443 43.6532 -79.3832
167 relay.artio.inf.unibe.ch 46.9501 7.43678
168 blossom.gnostr.cloud 43.6532 -79.3832
169 relay.binaryrobot.com 43.6532 -79.3832
170 relay.earthly.city 34.1749 -118.54
171 nostr.hifish.org 47.4244 8.57658
172 offchain.pub:443 39.1585 -94.5728
173 relay.bullishbounty.com:443 43.6532 -79.3832
174 strfry.openhoofd.nl:443 51.5717 3.70417
175 cs-relay.nostrdev.com:443 50.4754 12.3683
176 strfry.ymir.cloud 43.6532 -79.3832
177 nostrbtc.com 43.6532 -79.3832
178 relay.directsponsor.net 42.8864 -78.8784
179 nostr2.girino.org 43.6532 -79.3832
180 relay.sigit.io:443 50.4754 12.3683
181 relay.getsafebox.app 43.6532 -79.3832
182 antiprimal.net 43.6532 -79.3832
183 nostr.sathoarder.com 48.5734 7.75211
184 inbox.scuba323.com 40.8218 -74.45
185 nrs-01.darkcloudarcade.com:443 39.0997 -94.5786
186 nostr.tac.lol 47.4748 -122.273
187 nostr.davenov.com 50.1109 8.68213
188 relay.trotters.cc:443 43.6532 -79.3832
189 nostr.plantroon.com:443 50.1013 8.62643
190 relay.nostreon.com 60.1699 24.9384
191 nostr.easycryptosend.it 43.6532 -79.3832
192 nostr-01.yakihonne.com:443 1.32123 103.695
193 relay-testnet.k8s.layer3.news 37.3387 -121.885
194 nostr.purpura.cloud 43.6532 -79.3832
195 insta-relay.apps3.slidestr.net 40.4167 -3.70329
196 nostr.mifen.me 43.6532 -79.3832
197 testnet-relay.samt.st:443 40.8302 -74.1299
198 nostr.2b9t.xyz:443 34.0549 -118.243
199 relay.wavlake.com:443 41.2619 -95.8608
200 relay.wisp.talk:443 49.4543 11.0746
201 relay-dev.satlantis.io:443 39.0438 -77.4874
202 relay.satlantis.io 39.0438 -77.4874
203 relay.staging.plebeian.market 51.5072 -0.127586
204 relay.openfarmtools.org 60.1699 24.9384
205 relay.nostrhub.fr 48.1045 11.6004
206 nostr-relay.xbytez.io 50.6924 3.20113
207 relay.binaryrobot.com:443 43.6532 -79.3832
208 relay.samt.st 40.8302 -74.1299
209 relay.illuminodes.com 43.6532 -79.3832
210 relay.liberbitworld.org 43.6532 -79.3832
211 relay.olas.app:443 60.1699 24.9384
212 no.str.cr 8.96171 -83.5246
213 dm-test-strfry-discovery.samt.st 43.6532 -79.3832
214 wot.rejecttheframe.xyz 43.6532 -79.3832
215 relay.nostriot.com:443 41.5695 -83.9786
216 nostr.plantroon.com 50.1013 8.62643
217 nostr-01.uid.ovh 50.9871 2.12554
218 relay.openresist.com:443 43.6532 -79.3832
219 nostr.overmind.lol 43.6532 -79.3832
220 relay.internationalright-wing.org -22.5022 -48.7114
221 nostr.myshosholoza.co.za:443 52.3676 4.90414
222 nostr.pbfs.io:443 50.4754 12.3683
223 21milionidinostr.duckdns.org 41.8967 12.4822
224 nostr.4rs.nl 49.0291 8.35696
225 relay.lanavault.space 60.1699 24.9384
226 relay.mostr.pub 43.6532 -79.3832
227 relay.nostar.org 43.6532 -79.3832
228 nostr.mom 50.4754 12.3683
229 relay.decentralia.fr 48.122 11.589
230 relay.agentry.com 42.8864 -78.8784
231 relay2.angor.io 48.1046 11.6002
232 slick.mjex.me 39.0418 -77.4744
233 relay-us.zombi.cloudrodion.com 40.7862 -74.0743
234 relay.vrtmrz.net:443 43.6532 -79.3832
235 relay.beginningend.com 35.2227 -97.4786
236 chat-relay.zap-work.com:443 43.6532 -79.3832
237 relay.underorion.se 50.1109 8.68213
238 relay.mitchelltribe.com 39.0438 -77.4874
239 relay.qstr.app 51.5072 -0.127586
240 relay.cyberguy.fyi 52.6907 4.8181
241 strfry.bonsai.com:443 39.0438 -77.4874
242 relayone.soundhsa.com:443 39.0997 -94.5786
243 relay.sigit.io 50.4754 12.3683
244 relay.npubhaus.com 43.6532 -79.3832
245 relayrs.notoshi.win 43.6532 -79.3832
246 relay.mitchelltribe.com:443 39.0438 -77.4874
247 relay.44billion.net 43.6532 -79.3832
248 reraw.pbla2fish.cc 43.6532 -79.3832
249 articles.layer3.news:443 37.3387 -121.885
250 nostr.sovereignservices.xyz 43.6532 -79.3832
251 relay.nostx.io 43.6532 -79.3832
252 nostr-relay.amethyst.name 39.0067 -77.4291
253 0x-nostr-relay.fly.dev 37.7648 -122.432
254 relay.ohstr.com:443 43.6532 -79.3832
255 00f2e774.relay.dev.thunderegg.us 39.0438 -77.4874
256 nostr-relay.cbrx.io 43.6532 -79.3832
257 relay.wavlake.com 41.2619 -95.8608
258 purplerelay.com:443 43.6532 -79.3832
259 nostr-pr02.redscrypt.org 52.3676 4.90414
260 fanfares.nostr1.com:443 40.7057 -74.0136
261 kasztanowa.bieda.it 43.6532 -79.3832
262 relay.flashapp.me 43.6548 -79.3885
263 relay.typedcypher.com 51.5072 -0.127586
264 nostr.bond 50.1109 8.68213
265 nostr.azzamo.net 52.2633 21.0283
266 nexus.libernet.app 43.6532 -79.3832
267 relay.cosmicbolt.net 37.3986 -121.964
268 schnorr.me 43.6532 -79.3832
269 relay.mostro.network:443 40.8302 -74.1299
270 relay-arg.zombi.cloudrodion.com 1.35208 103.82
271 relay.chorus.community 48.5333 10.7
272 blossom.gnostr.cloud:443 43.6532 -79.3832
273 syb.lol:443 34.0549 -118.243
274 relay.dyne.org 49.0291 8.35705
275 btc.klendazu.com 41.2861 1.24993
276 wot.nostr.place 43.6532 -79.3832
277 relay.openresist.com 43.6532 -79.3832
278 rilo.nostria.app:443 43.6532 -79.3832
279 no.str.cr:443 8.96171 -83.5246
280 relay.mostr.pub:443 43.6532 -79.3832
281 relay.edufeed.org:443 49.4521 11.0767
282 nostr.debate.report 50.1109 8.68213
283 relay.satmaxt.xyz:443 43.6532 -79.3832
284 relay.artx.market:443 43.6548 -79.3885
285 relay-dev.gulugulu.moe 43.6532 -79.3832
286 relay.novospes.com 43.6532 -79.3832
287 relay.nostr-check.me 43.6532 -79.3832
288 nostr.computingcache.com 34.0356 -118.442
289 nostr.oxtr.dev 50.4754 12.3683
290 relay.fckstate.net 59.3293 18.0686
291 relay.vrtmrz.net 43.6532 -79.3832
292 relay.bornheimer.app 51.5072 -0.127586
293 relay.guggero.org 46.5971 9.59652
294 relay01.lnfi.network 35.6764 139.65
295 wot.shaving.kiwi 43.6532 -79.3832
296 nostr.twinkle.lol 51.902 7.6657
297 relay.edufeed.org 49.4521 11.0767
298 relay.lanacoin-eternity.com 40.8302 -74.1299
299 relay.satmaxt.xyz 43.6532 -79.3832
300 nostr.hifish.org:443 47.4244 8.57658
301 relay.cypherflow.ai:443 48.8575 2.35138
302 infinity-signal-relay.digitalforlifeagency.workers.dev 43.6532 -79.3832
303 nostr.na.social:443 43.6532 -79.3832
304 nostr.rtvslawenia.com:443 49.4543 11.0746
305 relay.mypathtofire.de 42.8864 -78.8784
306 public.crostr.com 43.6532 -79.3832
307 relay.olas.app 60.1699 24.9384
308 relay.agora.social 50.7383 15.0648
309 ribo.nostria.app 43.6532 -79.3832
310 relay.lab.rytswd.com 49.4543 11.0746
311 relay.ditto.pub:443 43.6532 -79.3832
312 porchlight.social 43.6532 -79.3832
313 nostr.notribe.net 40.8302 -74.1299
314 relay.endfiat.money 59.3327 18.0656
315 nostr.myshosholoza.co.za 52.3676 4.90414
316 relay.nearhood.co.uk 51.5134 -0.0890675
317 relay.degmods.com 50.4754 12.3683
318 nostr.novacisko.cz 52.2026 20.9397
319 prl.plus 55.7628 37.5983
320 bruh.samt.st 43.6532 -79.3832
321 strfry.openhoofd.nl 51.5717 3.70417
322 nostr.spicyz.io 43.6532 -79.3832
323 nostr.na.social 43.6532 -79.3832
324 nip85.nosfabrica.com 39.0997 -94.5786
325 premium.primal.net 43.6532 -79.3832
326 fanfares.nostr1.com 40.7057 -74.0136
327 relay.scuba323.com 40.8218 -74.45
328 nostr2.girino.org:443 43.6532 -79.3832
329 relay.mmwaves.de 48.8575 2.35138
330 nostr-rs-relay.dev.fedibtc.com:443 39.0438 -77.4874
331 strfry.shock.network:443 39.0438 -77.4874
332 nostr.snowbla.de 50.4754 12.3683
333 nostr.spaceshell.xyz 43.6532 -79.3832
334 nostr.quali.chat 60.1699 24.9384
335 wot.utxo.one 43.6532 -79.3832
336 relay.mccormick.cx 52.3563 4.95714
337 mostro-p2p.tech 50.1109 8.68213
338 basspistol.org 49.0291 8.35696
339 ribo.nostria.app:443 43.6532 -79.3832
340 chorus.mikedilger.com:444 -36.8906 174.794
341 nostr.oxtr.dev:443 50.4754 12.3683
342 nostr.nodesmap.com 59.3327 18.0656
343 offchain.bostr.online 43.6532 -79.3832
344 purplerelay.com 43.6532 -79.3832
345 relayrs.notoshi.win:443 43.6532 -79.3832
346 relay.wavefunc.live 41.8781 -87.6298
347 relay.dreamith.to 43.6532 -79.3832
348 bendernostur.duckdns.org:8443 50.1109 8.68213
349 relay.nmail.li 50.9871 2.12554
350 nostr-relay.corb.net 39.6478 -104.988
351 relay.staging.plebeian.market:443 51.5072 -0.127586
352 spamspamspamspam.rest 43.6532 -79.3832
353 relay1.gfcom.info 13.9215 100.538
354 schnorr.me:443 43.6532 -79.3832
355 relay.lab.rytswd.com:443 49.4543 11.0746
356 nostr-rs-relay.dev.fedibtc.com 39.0438 -77.4874
357 dm-test-nostr-rs-42-disabled.samt.st 43.6532 -79.3832
358 relay.nostrmap.net 60.1699 24.9384
359 nostr.relay.hedwig.sh 60.1699 24.9384
360 relay.veganostr.com:443 60.1699 24.9384
361 relay.wavefunc.live:443 41.8781 -87.6298
362 nostr.mikoshi.de 52.52 13.405
363 syb.lol 34.0549 -118.243
364 relay1.nostrchat.io 60.1699 24.9384
365 nostr.wecsats.io:443 43.6532 -79.3832
366 nostr.chaima.info:443 51.5072 -0.127586
367 nostr.azzamo.net:443 52.2633 21.0283
368 relay-can.zombi.cloudrodion.com 43.6532 -79.3832
369 nostr.unkn0wn.world 46.8499 9.53287
370 relayone.soundhsa.com 39.0997 -94.5786
371 x.kojira.io 43.6532 -79.3832
372 dm-test-strfry-discovery.samt.st:443 43.6532 -79.3832
373 nostrelay.circum.space 52.6907 4.8181
374 relay.primal.net 43.6532 -79.3832
375 nostr.girino.org 43.6532 -79.3832
376 nostr.pbfs.io 50.4754 12.3683
377 relay.kalcafe.xyz 37.3986 -121.964
378 relay.gulugulu.moe 43.6532 -79.3832
379 top.testrelay.top 43.6532 -79.3832
380 relay.kilombino.com 43.6532 -79.3832
381 nos.lol:443 50.4754 12.3683
382 nos.lol 50.4754 12.3683
383 relay.nostr.place:443 43.6532 -79.3832
384 cache.trustr.ing 43.6548 -79.3885
385 relay.internationalright-wing.org:443 -22.5022 -48.7114
386 relay.laantungir.net -19.4692 -42.5315
387 relay.lightning.pub:443 39.0438 -77.4874
388 nostr.stakey.net 52.3676 4.90414
389 articles.layer3.news 37.3387 -121.885
390 relay.wisp.talk 49.4543 11.0746
391 relay.pyramid.li 47.4093 8.46503
392 relay.typedcypher.com:443 51.5072 -0.127586
393 dev.relay.stream 43.6532 -79.3832
394 relay.bullishbounty.com 43.6532 -79.3832
395 nostr.mom:443 50.4754 12.3683
396 relay.plebeian.market:443 50.1109 8.68213
397 nostr.hekster.org 37.3986 -121.964
398 nostrcity-club.fly.dev 37.7648 -122.432
399 nostr.vulpem.com 49.4543 11.0746
400 relay-dev.gulugulu.moe:443 43.6532 -79.3832
401 weboftrust.libretechsystems.xyz 55.4724 9.87335
402 nostr-relay.corb.net:443 39.6478 -104.988
403 wheat.happytavern.co 43.6532 -79.3832
404 relay.mappingbitcoin.com 43.6532 -79.3832
405 testnet-relay.samt.st 40.8302 -74.1299
406 relay.bitmacro.cloud 43.6532 -79.3832
407 dev.relay.edufeed.org 49.4521 11.0767
408 myvoiceourstory.org 37.3598 -121.981
409 relay.stickeroo.is-cool.dev 37.3387 -121.885
410 relay.agorist.space 52.3734 4.89406
411 freelay.sovbit.host 60.1699 24.9384
412 nostr-dev.wellorder.net 45.5201 -122.99
413 nostr.middling.mydns.jp 35.8099 140.12
414 cs-relay.nostrdev.com 50.4754 12.3683
415 x.kojira.io:443 43.6532 -79.3832
416 nostrelay.circum.space:443 52.6907 4.8181
417 nostr.janx.com 43.6532 -79.3832
418 relay.mrmave.work 43.6532 -79.3832
419 espelho.girino.org 43.6532 -79.3832
420 hol.is 43.6532 -79.3832
421 ribo.eu.nostria.app 43.6532 -79.3832
422 nostr.yutakobayashi.com 43.6532 -79.3832
423 relay.mostro.network 40.8302 -74.1299
424 communities.nos.social 40.8302 -74.1299
425 relay.solife.me 43.6532 -79.3832
426 yabu.me 35.6092 139.73
427 relay.islandbitcoin.com 12.8498 77.6545
428 nostr.wecsats.io 43.6532 -79.3832
429 nostr.tac.lol:443 47.4748 -122.273
430 relay.arx-ccn.com 50.4754 12.3683
431 nostrride.io 37.3986 -121.964
432 r.0kb.io:443 32.789 -96.7989
433 herbstmeister.com 34.0549 -118.243
434 relay.artx.market 43.6548 -79.3885
435 vault.iris.to 43.6532 -79.3832
436 relay.ru.ac.th 13.7607 100.627
437 temp.iris.to 43.6532 -79.3832
438 social.amanah.eblessing.co 48.1046 11.6002
439 nostr-relay.nextblockvending.com 47.2343 -119.853
440 wot.codingarena.top 50.4754 12.3683
441 relay.sincensura.org 43.6532 -79.3832
442 nostr.dlcdevkit.com 40.0992 -83.1141

View File

@ -36,6 +36,24 @@ randomization used by senders. Android caps outbound seal and gift-wrap
randomization at 22h, leaving 2 hours of slack inside iOS's 24-hour
subscription window, while retaining its 48-hour receive lookback.
Geohash relay selection is part of the cross-client contract. Both platforms
read `relays/online_relays_gps.csv` from the bitchat repo, key each row by its
host string (lowercased, an explicit port kept unless it is 443, the wss
default), deduplicate by that key, and order candidates by distance with ties
broken by the same key. Clients that select differently can end up on disjoint
relay sets for the same geohash and silently fail to exchange messages.
`RelayDirectoryTest` covers the Android side; iOS implements the same rules in
`GeoRelayDirectory`.
Directory acceptance is part of the same contract. Both platforms validate a
directory file with the same rules (exact header, per-row host and coordinate
checks, size, row, and entry caps, and a minimum overlap with the previous
entries for downloads) and reject a violating file whole, keeping the previous
copy. A file one platform accepts and the other rejects splits the two relay
selections at every geohash at once. `RelayDirectoryValidationTest` covers the
Android side; iOS implements the same rules in
`GeoRelayDirectory.validatedEntries`.
## Rewrite acceptance gate
From a configured Android development environment, run: