feat(quartz): NIP-66 check options — read-test filters, write-test event, cached NIP-11 fetcher

RelayProber.probe()/probeFlow() take a filters option choosing the check:
LIVENESS_FILTERS (default, impossible-id REQ — EOSE proves liveness with no
payload) or readTestFilter(limit = 1) — a REQ the relay must actually work
for, querying and streaming real events, making Verdict.rttEoseMs a genuine
read test.

RelayProbeWriteTest.build() creates the write-check event: ephemeral kind
20166 (never stored by compliant relays) carrying a NIP-40 expiration tag
60s out as belt-and-braces for relays that store unknown ephemeral kinds.
Publish it under the monitor key, time the OK for rtt-write, map rejection
prefixes to R requirement tags — an OK false still proves the write path.

Nip11Fetcher is the missing fetch seam for relay information documents,
mirroring Nip05Fetcher: the interface lives in commonMain,
OkHttpNip11Fetcher (jvmAndroid) does the Accept: application/nostr+json
GET, and CachedNip11Fetcher wraps any implementation with a TTL cache —
successes trusted for a day, failures remembered for five minutes so a
census doesn't hammer hosts that just refused.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK
This commit is contained in:
Claude
2026-08-04 14:18:05 +00:00
parent d9a58950ec
commit a7eec1d605
7 changed files with 465 additions and 7 deletions
@@ -0,0 +1,97 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip11RelayInfo
import androidx.collection.LruCache
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CancellationException
/**
* TTL cache around any [Nip11Fetcher]. NIP-11 documents change rarely, so a
* successful fetch is served from memory for [ttlSeconds]; a FAILED fetch is
* also remembered — for the shorter [errorTtlSeconds] — so a mass census does
* not hammer a host that just refused, while still retrying it soon.
*
* Concurrent first fetches of the same relay are not deduplicated: both hit the
* network and the second result wins the cache slot. That is harmless (the
* document is idempotent) and keeps this class lock-free.
*/
class CachedNip11Fetcher(
private val delegate: Nip11Fetcher,
private val ttlSeconds: Long = DEFAULT_TTL_SECONDS,
private val errorTtlSeconds: Long = DEFAULT_ERROR_TTL_SECONDS,
maxEntries: Int = 1000,
private val now: () -> Long = { TimeUtils.now() },
) : Nip11Fetcher {
private sealed interface Cached {
val at: Long
}
private class Hit(
val info: Nip11RelayInformation,
override val at: Long,
) : Cached
private class Miss(
val message: String?,
override val at: Long,
) : Cached
private val cache = LruCache<NormalizedRelayUrl, Cached>(maxEntries)
/** The cached document if present and fresh; null otherwise. Never touches the network. */
fun cachedOrNull(relay: NormalizedRelayUrl): Nip11RelayInformation? {
val hit = cache[relay] as? Hit ?: return null
return if (now() - hit.at < ttlSeconds) hit.info else null
}
/** Drops the cache entry (success or failure) so the next [fetch] is fresh. */
fun invalidate(relay: NormalizedRelayUrl) {
cache.remove(relay)
}
override suspend fun fetch(relay: NormalizedRelayUrl): Nip11RelayInformation {
when (val cached = cache[relay]) {
is Hit -> if (now() - cached.at < ttlSeconds) return cached.info
is Miss ->
if (now() - cached.at < errorTtlSeconds) {
throw Nip11FetchException(cached.message ?: "cached NIP-11 failure for ${relay.url}")
}
null -> {}
}
return try {
delegate.fetch(relay).also { cache.put(relay, Hit(it, now())) }
} catch (e: Exception) {
if (e is CancellationException) throw e
cache.put(relay, Miss(e.message, now()))
throw e
}
}
companion object {
/** Documents are near-static: trust a success for a day. */
const val DEFAULT_TTL_SECONDS = 24L * 60 * 60
/** Failures are often transient: retry after five minutes. */
const val DEFAULT_ERROR_TTL_SECONDS = 5L * 60
}
}
@@ -0,0 +1,44 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip11RelayInfo
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Fetches and parses a relay's NIP-11 information document (the
* `application/nostr+json` answer on the relay's https url). Mirrors the
* [com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Fetcher] seam: the HTTP
* transport lives in a platform implementation (OkHttpNip11Fetcher on
* JVM/Android), so common code — probes, monitors, the CLI — depends only on
* this interface. Wrap any implementation in [CachedNip11Fetcher] to add a TTL
* cache.
*
* Throws [Nip11FetchException] (or a transport exception) when the document is
* unavailable or unparseable.
*/
interface Nip11Fetcher {
suspend fun fetch(relay: NormalizedRelayUrl): Nip11RelayInformation
}
/** The relay answered, but not with a usable NIP-11 document (bad status, not JSON). */
class Nip11FetchException(
message: String,
) : Exception(message)
@@ -0,0 +1,59 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip66RelayMonitor.reachability
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* The event a NIP-66 monitor publishes to measure a relay's WRITE path: publish
* one of these (signed with the monitor key), time the `OK`, and read the
* rejection prefix when refused (`auth-required:`/`restricted:`/`pow:` map to
* the discovery record's `R` requirement tags; a timed acceptance is `rtt-write`).
*
* The kind is EPHEMERAL (2000029999 per NIP-01), so a compliant relay serves it
* to current subscribers and never stores it — the probe leaves nothing behind.
* [KIND] 20166 is this library's convention (30166 discovery minus the
* addressable range), not something NIP-66 standardizes; any ephemeral kind
* works. Belt-and-braces, the template also carries a NIP-40 `expiration` tag
* [EXPIRATION_SECONDS] out, so a relay that stores unknown ephemeral kinds
* anyway purges it promptly.
*
* A rejection is still a MEASUREMENT: an `OK false` proves the write path works
* and documents the relay's policy. Only silence is a failed write test.
*/
object RelayProbeWriteTest {
const val KIND = 20166
/** Storage-window ceiling for non-compliant relays that store ephemeral events. */
const val EXPIRATION_SECONDS = 60L
fun build(
content: String = "NIP-66 write probe",
createdAt: Long = TimeUtils.now(),
): EventTemplate<Event> =
eventTemplate(KIND, content, createdAt) {
add(ExpirationTag.assemble(createdAt + EXPIRATION_SECONDS))
}
}
@@ -100,18 +100,24 @@ class RelayProber(
/**
* Probe every relay in [relays], [waveSize] at a time, giving each wave up to
* [timeoutMs] to reach terminals. Returns one [Verdict] per input relay.
*
* [filters] is the REQ each relay is asked to answer. The default
* [LIVENESS_FILTERS] matches nothing, so an EOSE proves liveness without
* streaming a payload; pass [readTestFilter] to make [Verdict.rttEoseMs] a
* real read test instead (the relay must query and stream an actual event).
*/
suspend fun probe(
relays: Collection<NormalizedRelayUrl>,
timeoutMs: Long = 15_000,
waveSize: Int = 1000,
filters: List<Filter> = LIVENESS_FILTERS,
): Result {
val mark = TimeSource.Monotonic.markNow()
val all = ArrayList<Verdict>(relays.size)
val distinct = relays.toSet()
var done = 0
for (wave in distinct.chunked(waveSize.coerceAtLeast(1))) {
probeWave(wave, timeoutMs) { all += it }
probeWave(wave, timeoutMs, filters) { all += it }
done += wave.size
if (distinct.size > wave.size) {
val liveSoFar = all.count { it.reachable }
@@ -127,6 +133,9 @@ class RelayProber(
* as its EOSE/CLOSED/connect-failure lands, not when the whole census ends. Only
* relays that stay silent wait for their wave's [timeoutMs] deadline.
*
* [filters] picks the check, as in [probe]: [LIVENESS_FILTERS] (default) or
* [readTestFilter].
*
* Probing starts when the flow is collected and pauses between waves while the
* collector is busy (emission is sequential). Pair each verdict with
* [toDiscoveryEventTemplate] to turn the stream into signable NIP-66 kind:30166
@@ -136,16 +145,18 @@ class RelayProber(
relays: Collection<NormalizedRelayUrl>,
timeoutMs: Long = 15_000,
waveSize: Int = 1000,
filters: List<Filter> = LIVENESS_FILTERS,
): Flow<Verdict> =
flow {
for (wave in relays.toSet().chunked(waveSize.coerceAtLeast(1))) {
probeWave(wave, timeoutMs) { emit(it) }
probeWave(wave, timeoutMs, filters) { emit(it) }
}
}
private suspend fun probeWave(
wave: List<NormalizedRelayUrl>,
timeoutMs: Long,
filters: List<Filter>,
onVerdict: suspend (Verdict) -> Unit,
) {
val mark = TimeSource.Monotonic.markNow()
@@ -230,7 +241,7 @@ class RelayProber(
client.addConnectionListener(connListener)
try {
client.subscribe(subId, wave.associateWith { PROBE_FILTERS }, subListener)
client.subscribe(subId, wave.associateWith { filters }, subListener)
val remaining = wave.toMutableSet()
while (remaining.isNotEmpty()) {
val left = timeoutMs - mark.elapsedNow().inWholeMilliseconds
@@ -251,10 +262,21 @@ class RelayProber(
}
companion object {
// A filter no event can match (ids are 64-hex of a hash): the relay answers
// with an immediate EOSE and never streams a payload. Same trick as the
// crawler's warm pool.
private val PROBE_FILTERS = listOf(Filter(ids = listOf("0".repeat(64))))
/**
* A filter no event can match (ids are 64-hex of a hash): the relay answers
* with an immediate EOSE and never streams a payload. Same trick as the
* crawler's warm pool. This is the default check — pure liveness.
*/
val LIVENESS_FILTERS = listOf(Filter(ids = listOf("0".repeat(64))))
/**
* A REQ the relay must actually WORK for: query its store and stream up to
* [limit] real events before the EOSE. Pass to [probe]/[probeFlow] as
* [filters] to turn [Verdict.rttEoseMs] into a genuine read test rather
* than a liveness ping — the time still counts from the wave start (dial
* included), so compare it against [Verdict.rttOpenMs], not across waves.
*/
fun readTestFilter(limit: Int = 1) = listOf(Filter(limit = limit))
/**
* The relay universe the local store knows: every read/write relay advertised
@@ -0,0 +1,125 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip11RelayInfo
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertNull
class CachedNip11FetcherTest {
private val relay = RelayUrlNormalizer.normalize("wss://nostr.example.com")
/** Counts network hits; serves [doc] or throws when [failing]. */
private class FakeFetcher : Nip11Fetcher {
var calls = 0
var failing = false
var doc = Nip11RelayInformation(name = "v1")
override suspend fun fetch(relay: NormalizedRelayUrl): Nip11RelayInformation {
calls++
if (failing) throw Nip11FetchException("boom")
return doc
}
}
private fun cached(
delegate: FakeFetcher,
clock: () -> Long,
) = CachedNip11Fetcher(delegate, ttlSeconds = 100, errorTtlSeconds = 10, now = clock)
@Test
fun freshSuccessIsServedFromCache() =
runBlocking {
val net = FakeFetcher()
var now = 0L
val fetcher = cached(net) { now }
assertEquals("v1", fetcher.fetch(relay).name)
now = 99
assertEquals("v1", fetcher.fetch(relay).name)
assertEquals(1, net.calls, "second fetch inside the TTL must not touch the network")
}
@Test
fun successExpiresAfterTtl() =
runBlocking {
val net = FakeFetcher()
var now = 0L
val fetcher = cached(net) { now }
fetcher.fetch(relay)
net.doc = Nip11RelayInformation(name = "v2")
now = 100
assertEquals("v2", fetcher.fetch(relay).name)
assertEquals(2, net.calls)
}
@Test
fun failureIsCachedForItsOwnShorterTtl() =
runBlocking {
val net = FakeFetcher().apply { failing = true }
var now = 0L
val fetcher = cached(net) { now }
assertFailsWith<Nip11FetchException> { fetcher.fetch(relay) }
now = 9
assertFailsWith<Nip11FetchException> { fetcher.fetch(relay) }
assertEquals(1, net.calls, "a fresh failure must be served from cache, not re-fetched")
now = 10
net.failing = false
assertEquals("v1", fetcher.fetch(relay).name)
assertEquals(2, net.calls, "an expired failure must be retried")
}
@Test
fun invalidateForcesAFreshFetch() =
runBlocking {
val net = FakeFetcher()
val fetcher = cached(net) { 0 }
fetcher.fetch(relay)
fetcher.invalidate(relay)
fetcher.fetch(relay)
assertEquals(2, net.calls)
}
@Test
fun cachedOrNullNeverTouchesTheNetwork() =
runBlocking {
val net = FakeFetcher()
var now = 0L
val fetcher = cached(net) { now }
assertNull(fetcher.cachedOrNull(relay))
assertEquals(0, net.calls)
fetcher.fetch(relay)
assertEquals("v1", fetcher.cachedOrNull(relay)?.name)
now = 100
assertNull(fetcher.cachedOrNull(relay), "a stale hit must not be served")
assertEquals(1, net.calls)
}
}
@@ -49,6 +49,7 @@ class RelayProberFlowTest {
/** Captures the probe subscription so the test can play the relays. */
private class ScriptedClient : INostrClient by EmptyNostrClient() {
var listener: SubscriptionListener? = null
var sentFilters: Map<NormalizedRelayUrl, List<Filter>>? = null
override fun subscribe(
subId: String,
@@ -56,6 +57,7 @@ class RelayProberFlowTest {
listener: SubscriptionListener?,
) {
this.listener = listener
this.sentFilters = filters
}
}
@@ -139,6 +141,55 @@ class RelayProberFlowTest {
assertTrue(at < 1_000, "a failed dial must not wait for the deadline, arrived at ${at}ms")
}
// ------------------------------------------------------------------
// Check options — liveness default, read-test override, write-test event
// ------------------------------------------------------------------
@Test
fun livenessFilterIsTheDefaultCheck() =
runTest {
val client = ScriptedClient()
val collector =
launch {
RelayProber(client).probeFlow(listOf(fast), timeoutMs = 1_000).collect {}
}
launch {
delay(10)
assertEquals(RelayProber.LIVENESS_FILTERS, client.sentFilters!![fast])
client.listener!!.onEose(fast, null)
}
collector.join()
}
@Test
fun readTestFilterIsSentWhenChosen() =
runTest {
val client = ScriptedClient()
val collector =
launch {
RelayProber(client)
.probeFlow(listOf(fast), timeoutMs = 1_000, filters = RelayProber.readTestFilter())
.collect {}
}
launch {
delay(10)
val sent = client.sentFilters!![fast]!!.single()
assertEquals(1, sent.limit, "read test defaults to limit 1")
assertNull(sent.ids, "read test must query real events, not the impossible id")
client.listener!!.onEose(fast, null)
}
collector.join()
}
@Test
fun writeTestEventIsEphemeralAndSelfExpiring() {
val template = RelayProbeWriteTest.build(createdAt = 5000)
assertEquals(20166, template.kind)
assertTrue(template.kind in 20000..29999, "the write probe must be an ephemeral kind")
assertTrue(listOf("expiration", "5060") in template.tags.map { it.toList() })
}
// ------------------------------------------------------------------
// toDiscoveryEventTemplate — only observed facts become tags
// ------------------------------------------------------------------
@@ -0,0 +1,60 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip11RelayInfo
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.coroutines.executeAsync
/**
* OkHttp-backed [Nip11Fetcher]: GETs the relay's https url with
* `Accept: application/nostr+json` and parses the document. The client is
* resolved per relay so callers can route Tor/proxy relays through a different
* OkHttp instance (the same seam Amethyst's Nip11Retriever uses).
*/
class OkHttpNip11Fetcher(
private val okHttpClient: (NormalizedRelayUrl) -> OkHttpClient,
) : Nip11Fetcher {
override suspend fun fetch(relay: NormalizedRelayUrl): Nip11RelayInformation =
withContext(Dispatchers.IO) {
val request =
Request
.Builder()
.header("Accept", "application/nostr+json")
.url(relay.toHttp())
.build()
okHttpClient(relay).newCall(request).executeAsync().use { response ->
if (!response.isSuccessful) {
throw Nip11FetchException("HTTP ${response.code} fetching NIP-11 from ${relay.url}")
}
val body = response.body.string()
if (!body.startsWith("{")) {
throw Nip11FetchException("Not a NIP-11 document from ${relay.url}")
}
Nip11RelayInformation.fromJson(body)
}
}
}