From a7eec1d605728ca931b6e596e6f9832091d4c358 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 14:18:05 +0000 Subject: [PATCH] =?UTF-8?q?feat(quartz):=20NIP-66=20check=20options=20?= =?UTF-8?q?=E2=80=94=20read-test=20filters,=20write-test=20event,=20cached?= =?UTF-8?q?=20NIP-11=20fetcher?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01A9sSyh1QLJD3PZ18tVPPVK --- .../nip11RelayInfo/CachedNip11Fetcher.kt | 97 ++++++++++++++ .../quartz/nip11RelayInfo/Nip11Fetcher.kt | 44 ++++++ .../reachability/RelayProbeWriteTest.kt | 59 +++++++++ .../reachability/RelayProber.kt | 36 ++++- .../nip11RelayInfo/CachedNip11FetcherTest.kt | 125 ++++++++++++++++++ .../reachability/RelayProberFlowTest.kt | 51 +++++++ .../nip11RelayInfo/OkHttpNip11Fetcher.kt | 60 +++++++++ 7 files changed, 465 insertions(+), 7 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/CachedNip11Fetcher.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11Fetcher.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProbeWriteTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/CachedNip11FetcherTest.kt create mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/OkHttpNip11Fetcher.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/CachedNip11Fetcher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/CachedNip11Fetcher.kt new file mode 100644 index 0000000000..0805118b6b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/CachedNip11Fetcher.kt @@ -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(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 + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11Fetcher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11Fetcher.kt new file mode 100644 index 0000000000..cc97860576 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/Nip11Fetcher.kt @@ -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) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProbeWriteTest.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProbeWriteTest.kt new file mode 100644 index 0000000000..12bc086047 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProbeWriteTest.kt @@ -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 (20000–29999 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 = + eventTemplate(KIND, content, createdAt) { + add(ExpirationTag.assemble(createdAt + EXPIRATION_SECONDS)) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt index 364f4e1bd0..58663c2728 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProber.kt @@ -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, timeoutMs: Long = 15_000, waveSize: Int = 1000, + filters: List = LIVENESS_FILTERS, ): Result { val mark = TimeSource.Monotonic.markNow() val all = ArrayList(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, timeoutMs: Long = 15_000, waveSize: Int = 1000, + filters: List = LIVENESS_FILTERS, ): Flow = 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, timeoutMs: Long, + filters: List, 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 diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/CachedNip11FetcherTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/CachedNip11FetcherTest.kt new file mode 100644 index 0000000000..95bd844de0 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/CachedNip11FetcherTest.kt @@ -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 { fetcher.fetch(relay) } + now = 9 + assertFailsWith { 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) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt index 34b1ce15fc..2efc801324 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/reachability/RelayProberFlowTest.kt @@ -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>? = 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 // ------------------------------------------------------------------ diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/OkHttpNip11Fetcher.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/OkHttpNip11Fetcher.kt new file mode 100644 index 0000000000..a28033c0d1 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip11RelayInfo/OkHttpNip11Fetcher.kt @@ -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) + } + } +}