Merge remote-tracking branch 'origin/main' into claude/armada-nip29-integration-lwqard

# Conflicts:
#	cli/tests/.gitignore
This commit is contained in:
Claude
2026-07-09 21:48:04 +00:00
226 changed files with 21723 additions and 1072 deletions
@@ -0,0 +1,66 @@
/*
* 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.experimental.graperank
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
/**
* [GrapeRankCrawler.authorityOf] is the key the crawl's timeout-eviction counts
* on. It must collapse the many per-user path URLs the outbox model mints for one
* server into a single host, WITHOUT folding a distinct sibling host (e.g. a
* `filter.` subdomain) into its parent.
*/
class GrapeRankAuthorityTest {
private fun auth(url: String) = GrapeRankCrawler.authorityOf(url)
@Test
fun bareHostIsItsOwnAuthority() {
assertEquals("relay.damus.io", auth("wss://relay.damus.io"))
assertEquals("relay.damus.io", auth("wss://relay.damus.io/"))
assertEquals("nos.lol", auth("ws://nos.lol"))
}
@Test
fun perUserPathUrlsOnOneHostCollapseToOneAuthority() {
val a = auth("wss://filter.nostr.wine/npub1aaaa?broadcast=true")
val b = auth("wss://filter.nostr.wine/npub1bbbb?broadcast=true&global=all")
val c = auth("wss://filter.nostr.wine/?global=all")
assertEquals("filter.nostr.wine", a)
assertEquals(a, b)
assertEquals(a, c)
}
@Test
fun filterSubdomainIsNotFoldedIntoBareHost() {
// nostr.wine reads are open; filter.nostr.wine is a different server that may
// stall — evicting one must never take out the other.
assertNotEquals(auth("wss://filter.nostr.wine/npub1x"), auth("wss://nostr.wine"))
}
@Test
fun portIsPartOfTheAuthority() {
assertEquals("relay.veganostr.com:443", auth("wss://relay.veganostr.com:443/npub1z"))
assertEquals("81.68.170.122:7114", auth("ws://81.68.170.122:7114/"))
assertNotEquals(auth("wss://example.com:443"), auth("wss://example.com:8080"))
}
}
@@ -0,0 +1,246 @@
/*
* 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.experimental.graperank
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlin.math.abs
import kotlin.math.exp
import kotlin.math.ln
import kotlin.math.max
import kotlin.random.Random
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class GrapeRankTest {
private val obs = "observer"
private fun graphOf(edges: List<Triple<HexKey, HexKey, TrustRelation>>): TrustGraph {
val b = TrustGraphBuilder()
for ((source, target, relation) in edges) {
when (relation) {
TrustRelation.FOLLOW -> b.addFollows(source, listOf(target))
TrustRelation.MUTE -> b.addMutes(source, listOf(target))
TrustRelation.REPORT -> b.addReports(source, listOf(target))
}
}
return b.build()
}
private fun graphOf(vararg edges: Triple<HexKey, HexKey, TrustRelation>) = graphOf(edges.toList())
/** Score for a pubkey (0.0 if absent from the graph). */
private fun DoubleArray.of(
graph: TrustGraph,
pubkey: HexKey,
): Double {
val id = graph.idOf(pubkey)
return if (id < 0) 0.0 else this[id]
}
@Test
fun observerIsPinnedAtFullSelfTrust() {
val graph = graphOf(Triple(obs, "a", TrustRelation.FOLLOW))
val scores = GrapeRank().compute(graph, obs)
assertEquals(1.0, scores.of(graph, obs), 1e-12)
}
@Test
fun directFollowMatchesHandComputedValue() {
val graph = graphOf(Triple(obs, "a", TrustRelation.FOLLOW))
val scores = GrapeRank().compute(graph, obs)
// weight = 0.5 * 1.0 * 0.85 = 0.425 ; score = conf(0.425) = 0.2551612...
assertEquals(0.25516127, scores.of(graph, "a"), 1e-6)
}
@Test
fun trustDecaysSteeplyAcrossHops() {
val graph =
graphOf(
Triple(obs, "a", TrustRelation.FOLLOW),
Triple("a", "b", TrustRelation.FOLLOW),
)
val scores = GrapeRank().compute(graph, obs)
val a = scores.of(graph, "a")
val b = scores.of(graph, "b")
assertEquals(0.004499, b, 1e-5)
assertTrue(b < a / 10.0, "two-hop trust should be far below one-hop trust")
}
@Test
fun aMuteFromAnEndorsedUserLowersTheScore() {
val followOnlyGraph = graphOf(Triple(obs, "b", TrustRelation.FOLLOW))
val followOnly = GrapeRank().compute(followOnlyGraph, obs).of(followOnlyGraph, "b")
val muteGraph =
graphOf(
Triple(obs, "a", TrustRelation.FOLLOW),
Triple(obs, "b", TrustRelation.FOLLOW),
Triple("a", "b", TrustRelation.MUTE),
)
val withMute = GrapeRank().compute(muteGraph, obs).of(muteGraph, "b")
assertTrue(withMute < followOnly, "a mute from a trusted user should pull b below the follow-only baseline")
}
@Test
fun purelyReportedUserFloorsAtZero() {
val graph =
graphOf(
Triple(obs, "a", TrustRelation.FOLLOW),
Triple("a", "d", TrustRelation.REPORT),
)
val scores = GrapeRank().compute(graph, obs)
assertEquals(0.0, scores.of(graph, "d"), 1e-9)
}
@Test
fun unreachableUsersAreNotScored() {
val graph =
graphOf(
Triple(obs, "a", TrustRelation.FOLLOW),
Triple("x", "y", TrustRelation.FOLLOW),
)
val scores = GrapeRank().compute(graph, obs)
assertTrue(scores.of(graph, "a") > 0.0)
assertEquals(0.0, scores.of(graph, "y"), 1e-12, "a user with no path from the observer stays 0")
}
@Test
fun cyclesConverge() {
val graph =
graphOf(
Triple(obs, "a", TrustRelation.FOLLOW),
Triple("a", "b", TrustRelation.FOLLOW),
Triple("b", "a", TrustRelation.FOLLOW),
)
val scores = GrapeRank().compute(graph, obs)
assertTrue(scores.of(graph, "a") > 0.0)
assertTrue(scores.of(graph, "b") > 0.0)
}
@Test
fun deduplicatesRepeatedReportEdges() {
// Two report edges a->d collapse to one; the score matches a single report.
val once = graphOf(Triple(obs, "a", TrustRelation.FOLLOW), Triple("a", "d", TrustRelation.REPORT))
val twice =
graphOf(
Triple(obs, "a", TrustRelation.FOLLOW),
Triple("a", "d", TrustRelation.REPORT),
Triple("a", "d", TrustRelation.REPORT),
)
assertEquals(2, twice.edgeCount(), "duplicate report edge should be dropped")
assertEquals(
GrapeRank().compute(once, obs).of(once, "d"),
GrapeRank().compute(twice, obs).of(twice, "d"),
1e-12,
)
}
/**
* Adversarial cross-check: the worklist propagation must reach the same fixed
* point as a naive full-sweep (the reference `v1FullSweep`) on random graphs.
*/
@Test
fun worklistMatchesFullSweepOnRandomGraphs() {
val params = GrapeRankParams(convergence = 1e-10)
val engine = GrapeRank(params)
repeat(50) { seed ->
val rng = Random(seed)
val n = 3 + rng.nextInt(12)
val nodes = (0 until n).map { "u$it" }
val edges = ArrayList<Triple<HexKey, HexKey, TrustRelation>>()
for (src in nodes) {
for (dst in nodes) {
if (src == dst) continue
if (rng.nextDouble() < 0.25) {
val relation =
when (rng.nextInt(5)) {
0 -> TrustRelation.MUTE
1 -> TrustRelation.REPORT
else -> TrustRelation.FOLLOW
}
edges.add(Triple(src, dst, relation))
}
}
}
val observer = nodes.first()
val graph = graphOf(edges)
val scores = engine.compute(graph, observer)
val reference = fullSweep(edges, nodes, observer, params)
for (node in nodes) {
if (node == observer) continue // observer self-trust is not part of a ranking
val a = scores.of(graph, node)
val b = reference[node] ?: 0.0
assertEquals(b, a, 1e-5, "seed=$seed node=$node worklist=$a fullSweep=$b")
}
}
}
// Reference: blind full sweep over every user until nothing changes.
private fun fullSweep(
edges: List<Triple<HexKey, HexKey, TrustRelation>>,
nodes: List<HexKey>,
observer: HexKey,
params: GrapeRankParams,
): Map<HexKey, Double> {
// Dedup identical edges (mirrors the builder: report edges dedup; follow/mute
// sets are unique per source anyway).
val incoming = HashMap<HexKey, MutableSet<Pair<HexKey, TrustRelation>>>()
for ((s, t, r) in edges) {
if (s == t) continue
incoming.getOrPut(t) { LinkedHashSet() }.add(s to r)
}
fun confidence(
r: TrustRelation,
source: HexKey,
) = when (r) {
TrustRelation.FOLLOW -> if (source == observer) params.directFollowConfidence else params.indirectFollowConfidence
TrustRelation.MUTE -> params.muteConfidence
TrustRelation.REPORT -> params.reportConfidence
}
fun weightToConfidence(w: Double) = 1.0 - exp(-w * -ln(params.rigor))
val scores = HashMap<HexKey, Double>()
scores[observer] = 1.0
do {
var changed = false
for (target in nodes) {
if (target == observer) continue
var sumW = 0.0
var sumWR = 0.0
for ((source, r) in incoming[target] ?: emptySet()) {
val s = scores[source] ?: continue
val w = confidence(r, source) * s * params.attenuation
sumW += w
sumWR += w * r.rating
}
val newScore = if (abs(sumW) < 0.00001) 0.0 else max(weightToConfidence(sumW) * sumWR / sumW, 0.0)
val old = scores.put(target, newScore) ?: 0.0
changed = changed || abs(newScore - old) > params.convergence
}
} while (changed)
return scores
}
}
@@ -0,0 +1,107 @@
/*
* 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.experimental.graperank
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class TrustGraphBuilderTest {
private val alice = "alice"
private val bob = "bob"
private val carol = "carol"
private val dave = "dave"
/** Decode a node's incoming edges back to (source, relation) pairs from the CSR. */
private fun TrustGraph.incomingOf(pubkey: HexKey): Set<Pair<HexKey, TrustRelation>> {
val t = idOf(pubkey)
if (t < 0) return emptySet()
val out = HashSet<Pair<HexKey, TrustRelation>>()
var i = inOffsets[t]
val end = inOffsets[t + 1]
while (i < end) {
val packed = inPacked[i]
val source = pubkeyOf(packed and TrustGraph.SOURCE_MASK)
val relation = TrustRelation.entries.first { it.code == (packed ushr TrustGraph.SOURCE_BITS) }
out.add(source to relation)
i++
}
return out
}
@Test
fun buildsFollowMuteAndReportEdges() {
val b = TrustGraphBuilder()
b.addFollows(alice, listOf(bob, carol))
b.addMutes(bob, listOf(dave))
b.addReports(carol, listOf(dave))
val graph = b.build()
assertEquals(setOf(alice to TrustRelation.FOLLOW), graph.incomingOf(bob))
assertEquals(setOf(alice to TrustRelation.FOLLOW), graph.incomingOf(carol))
assertEquals(
setOf(bob to TrustRelation.MUTE, carol to TrustRelation.REPORT),
graph.incomingOf(dave),
)
}
@Test
fun dropsSelfEdges() {
val b = TrustGraphBuilder()
b.addFollows(alice, listOf(alice, bob))
val graph = b.build()
assertTrue(graph.incomingOf(alice).isEmpty(), "a self-follow must not become an edge")
assertEquals(setOf(alice to TrustRelation.FOLLOW), graph.incomingOf(bob))
}
@Test
fun dedupesRepeatedReports() {
val b = TrustGraphBuilder()
b.addReports(alice, listOf(dave))
b.addReports(alice, listOf(dave))
val graph = b.build()
assertEquals(1, graph.edgeCount())
assertEquals(setOf(alice to TrustRelation.REPORT), graph.incomingOf(dave))
}
@Test
fun keepsFollowAndMuteFromSameSourceAsDistinctEdges() {
val b = TrustGraphBuilder()
b.addFollows(alice, listOf(bob))
b.addMutes(alice, listOf(bob))
val graph = b.build()
assertEquals(
setOf(alice to TrustRelation.FOLLOW, alice to TrustRelation.MUTE),
graph.incomingOf(bob),
)
}
@Test
fun internsEachPubkeyOnce() {
val b = TrustGraphBuilder()
b.addFollows(alice, listOf(bob, carol))
b.addFollows(bob, listOf(carol))
val graph = b.build()
assertEquals(3, graph.nodeCount, "alice, bob, carol interned once each")
assertEquals(3, graph.edgeCount())
}
}
@@ -0,0 +1,86 @@
/*
* 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.nip01Core.relay.client.accessories
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class DrainFailureTest {
// Non-failure and non-"cannot" terminals are never dead signals.
@Test
fun nonFailureTerminalsAreNull() {
assertNull(classifyDrainFailure("eose"))
assertNull(classifyDrainFailure("closed:duplicate: sub"))
assertNull(classifyDrainFailure("timeout"))
}
// A READ timeout (or generic post-handshake timeout) is alive-but-slow: never
// dead. Measured 67% of these relays were reachable when re-probed fresh.
@Test
fun readTimeoutsStayRetryable() {
assertNull(classifyDrainFailure("cannot:Read timed out (SocketTimeoutException)"))
assertNull(classifyDrainFailure("cannot:timeout (SocketTimeoutException)"))
}
// An HTTP 429 rate-limit is alive and will serve us after backoff: never dead.
// Measured 4/4 such relays reachable when re-probed fresh.
@Test
fun rateLimitStaysRetryable() {
assertNull(classifyDrainFailure("cannot:Server Misconfigured. Response: 429 Too Many Requests (ProtocolException)"))
}
// Failing to ESTABLISH the connection is dead (0/30 reachable fresh). "connect
// timed out" must be caught as DEAD and NOT slip into the read-timeout branch.
@Test
fun connectEstablishmentFailuresAreDead() {
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Connect timed out (SocketTimeoutException)"))
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Unexpected response code for CONNECT: (IOException)"))
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Connection refused (ConnectException)"))
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Failed to connect to /1.2.3.4:443"))
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:No route to host (NoRouteToHostException)"))
}
// DNS and TLS misconfig can never work: DEAD.
@Test
fun dnsAndTlsAreDead() {
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Unable to resolve host (UnknownHostException)"))
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Received fatal alert: unrecognized_name (SSLHandshakeException)"))
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:PKIX path building failed: certificate (CertificateException)"))
}
// Every other bad HTTP upgrade won't serve us this run (measured 503 0%, 502 20%
// reachable; 402/403 gated; 200 not a relay) — DEAD, dropped on the first strike.
@Test
fun deadOrGatedHttpUpgradesAreDead() {
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Server Misconfigured. not a websocket"))
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Server Misconfigured. Response: 503 Service Unavailable (ProtocolException)"))
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Server Misconfigured. Response: 502 Bad Gateway (ProtocolException)"))
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Server Misconfigured. Response: 402 Payment Required (ProtocolException)"))
}
// A mid-stream reset won't hand us events this run either: DEAD.
@Test
fun midStreamResetIsDead() {
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Connection reset (SocketException)"))
assertEquals(DrainFailure.DEAD, classifyDrainFailure("cannot:Broken pipe (SocketException)"))
}
}
@@ -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.nip01Core.relay.client.pool
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class PoolEventOutboxStateTest {
private val relay = NormalizedRelayUrl("wss://relay.example/")
private fun fakeEvent() =
Event(
id = "0".repeat(64),
pubKey = "0".repeat(64),
createdAt = 0L,
kind = 1,
tags = emptyArray(),
content = "",
sig = "0".repeat(128),
)
@Test
fun authRequiredResponseDoesNotConsumeTryBudget() {
val state = PoolEventOutboxState(fakeEvent(), setOf(relay))
// Simulate 5 `auth-required:` responses — relay keeps challenging while
// RelayAuthenticator signs + sends AUTH events asynchronously. None of
// these should be counted against the 3-response try cap.
repeat(5) {
state.newResponse(relay, success = false, message = "auth-required: please authenticate")
}
// Even after a follow-up newTry, the relay must remain in the outbox so
// syncFilters() can re-publish once AUTH succeeds.
state.newTry(relay)
assertContains(state.relaysLeft(), relay)
assertFalse(state.isDone())
}
@Test
fun regularRejectionStillBoundedByTryCap() {
val state = PoolEventOutboxState(fakeEvent(), setOf(relay))
// 3 non-AUTH rejections accumulate normally.
repeat(3) {
state.newResponse(relay, success = false, message = "error: rate limited")
}
state.newTry(relay)
// After the 4th newTry (with 3 prior responses already in flight), the
// Tries cap kicks in and the relay is dropped from the outbox.
assertFalse(state.relaysLeft().contains(relay))
}
@Test
fun terminalRejectionImmediatelyDropsRelay() {
val state = PoolEventOutboxState(fakeEvent(), setOf(relay))
state.newResponse(relay, success = false, message = "invalid: malformed event")
assertFalse(state.relaysLeft().contains(relay))
assertTrue(state.isDone())
}
@Test
fun successDropsRelayFromOutbox() {
val state = PoolEventOutboxState(fakeEvent(), setOf(relay))
state.newResponse(relay, success = true, message = "")
assertEquals(emptySet(), state.relaysLeft())
assertTrue(state.isDone())
}
}
@@ -72,13 +72,15 @@ class BasicRelayClientTest {
}
@Test
fun onFailureWithMessageKeepsExistingFormat() {
fun onFailureWithMessageAppendsExceptionClassName() {
val (socket, listener) = connectAndCapture()
socket.onFailure(Exception("Connection reset"), null, null)
// The exception type is appended so listeners can classify the failure by
// its stable class name rather than by localized message text.
assertEquals(
listOf("WebSocket Failure: Connection reset"),
listOf("WebSocket Failure: Connection reset (Exception)"),
listener.cannotConnectMessages,
)
}
@@ -0,0 +1,127 @@
/*
* 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.nip01Core.store.sqlite
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import com.vitorpamplona.quartz.utils.EventFactory
import kotlin.test.Test
import kotlin.test.assertEquals
class AuthorsMissingOutboxTest : BaseDBTest() {
@Test
fun emptyStoreReturnsNoAuthors() =
forEachDB { db ->
assertEquals(emptySet(), db.authorsMissingOutbox().toSet())
}
@Test
fun authorWithEventButNoOutboxIsMissing() =
forEachDB { db ->
val signer = NostrSignerSync()
db.insert(signer.sign(TextNoteEvent.build("hello")))
assertEquals(setOf(signer.pubKey), db.authorsMissingOutbox().toSet())
}
@Test
fun authorWithOutboxIsNotMissing() =
forEachDB { db ->
val hasOutbox = NostrSignerSync()
val noOutbox = NostrSignerSync()
// Both authors have content; only one advertises a 10002.
db.insert(hasOutbox.sign(TextNoteEvent.build("with relays")))
db.insert(AdvertisedRelayListEvent.create(emptyList(), hasOutbox))
db.insert(noOutbox.sign(TextNoteEvent.build("no relays")))
assertEquals(setOf(noOutbox.pubKey), db.authorsMissingOutbox().toSet())
}
@Test
fun authorKnownOnlyByTheirOutboxIsNotMissing() =
forEachDB { db ->
// The only stored event for this author IS the 10002. They must
// not appear (the outer scan sees them, the NOT EXISTS excludes
// them) — the anti-join is symmetric on the same table.
val signer = NostrSignerSync()
db.insert(AdvertisedRelayListEvent.create(emptyList(), signer))
assertEquals(emptySet(), db.authorsMissingOutbox().toSet())
}
@Test
fun outboxDeletedMakesAuthorMissingAgain() =
forEachDB { db ->
val signer = NostrSignerSync()
db.insert(signer.sign(TextNoteEvent.build("content")))
val relayList = AdvertisedRelayListEvent.create(emptyList(), signer)
db.insert(relayList)
assertEquals(emptySet(), db.authorsMissingOutbox().toSet())
// NIP-09: the author deletes their own relay list. No 10002 row
// remains, so the anti-join reports them as missing again.
db.insert(signer.sign(DeletionEvent.build(listOf(relayList))))
assertEquals(setOf(signer.pubKey), db.authorsMissingOutbox().toSet())
}
@Test
fun giftWrapSenderIsNotCountedAsAuthor() =
forEachDB { db ->
val noteAuthor = NostrSignerSync()
db.insert(noteAuthor.sign(TextNoteEvent.build("hi")))
// A kind-1059 giftwrap stores an ephemeral one-time key as its
// pubkey (the real recipient is only a hash). It has no outbox and
// never will — but it must NOT be reported as "missing" one, or the
// result set would grow by one junk key per received DM.
val ephemeralSender = "aa".repeat(32)
db.insert(
EventFactory.create("bb".repeat(32), ephemeralSender, 1L, GiftWrapEvent.KIND, emptyArray(), "", "00".repeat(64)),
)
assertEquals(setOf(noteAuthor.pubKey), db.authorsMissingOutbox().toSet())
}
@Test
fun mixOfAuthorsReportsOnlyThoseWithoutOutbox() =
forEachDB { db ->
val a = NostrSignerSync()
val b = NostrSignerSync()
val c = NostrSignerSync()
db.insert(a.sign(TextNoteEvent.build("a1")))
db.insert(a.sign(TextNoteEvent.build("a2")))
db.insert(AdvertisedRelayListEvent.create(emptyList(), a))
db.insert(b.sign(TextNoteEvent.build("b1")))
db.insert(c.sign(TextNoteEvent.build("c1")))
db.insert(AdvertisedRelayListEvent.create(emptyList(), c))
assertEquals(setOf(b.pubKey), db.authorsMissingOutbox().toSet())
}
}
@@ -0,0 +1,104 @@
/*
* 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.nip59Giftwrap.wraps
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
/**
* NIP-17 relay-hint placement contract.
*
* Per NIP-17 §Publishing, the gift wrap's `p` tag MAY carry the recipient's
* primary DM inbox relay as a third element so other devices of the recipient
* can discover the wrap without a separate kind:10050 lookup. The hint
* deliberately lives on the public wrap, NOT on the encrypted seal — putting
* it on the seal would hide the routing information inside the encryption
* envelope, defeating the purpose.
*/
class GiftWrapRelayHintTest {
private val recipient = KeyPair()
private fun innerEvent(): Event {
val signer = NostrSignerSync(KeyPair())
return signer.sign(
createdAt = 0L,
kind = 1,
tags = emptyArray(),
content = "hello",
)
}
@Test
fun defaultsToNoRelayHintForBackwardsCompat() =
runTest {
// Existing callers that don't pass a hint must continue to emit the
// historical ["p", recipientPubKey] two-element tag shape.
val wrap =
GiftWrapEvent.create(
event = innerEvent(),
recipientPubKey = recipient.pubKey.toHexKey(),
)
val pTag = wrap.tags.first { it.firstOrNull() == "p" }
assertEquals(2, pTag.size, "p tag must be 2 elements when no hint passed")
assertEquals(recipient.pubKey.toHexKey(), pTag[1])
}
@Test
fun relayHintLandsOnWrapPTagAsThirdElement() =
runTest {
// When a hint is passed, it must appear as the THIRD element of the
// wrap's p tag — NIP-17 spec. Not inside the encrypted seal.
val hint = NormalizedRelayUrl("wss://dm.relay.example/")
val wrap =
GiftWrapEvent.create(
event = innerEvent(),
recipientPubKey = recipient.pubKey.toHexKey(),
recipientRelayHint = hint,
)
val pTag = wrap.tags.first { it.firstOrNull() == "p" }
assertEquals(3, pTag.size, "p tag carries [tag, pubkey, relay-hint]")
assertEquals(recipient.pubKey.toHexKey(), pTag[1])
assertEquals(hint.url, pTag[2])
}
@Test
fun absentHintDoesNotAddTrailingEmptyElement() =
runTest {
// Defensive: a null hint must not produce `["p", pubkey, ""]` — that
// would be a leak (broadcasts the user has no canonical inbox) and
// a wire-format change from the historical shape.
val wrap =
GiftWrapEvent.create(
event = innerEvent(),
recipientPubKey = recipient.pubKey.toHexKey(),
recipientRelayHint = null,
)
val pTag = wrap.tags.first { it.firstOrNull() == "p" }
assertNull(pTag.getOrNull(2), "third element must be absent, not empty string")
}
}
@@ -0,0 +1,108 @@
/*
* 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.utils.concurrent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
class ConcurrentCollectionsTest {
@Test
fun mapGetSet() {
val m = ConcurrentMap<String, Int>()
assertNull(m["a"])
m["a"] = 1
assertEquals(1, m["a"])
m["a"] = 2
assertEquals(2, m["a"])
assertEquals(1, m.size())
}
@Test
fun mapGetOrPutComputesOnce() {
val m = ConcurrentMap<String, Int>()
var calls = 0
assertEquals(
7,
m.getOrPut("k") {
calls++
7
},
)
// Present now: the default must NOT be recomputed.
assertEquals(
7,
m.getOrPut("k") {
calls++
99
},
)
assertEquals(1, calls)
assertEquals(7, m["k"])
}
@Test
fun mapMergeInsertsThenCombines() {
val m = ConcurrentMap<String, Int>()
// Absent -> inserts the value verbatim, remap not applied.
assertEquals(1, m.merge("k", 1) { a, b -> a + b })
// Present -> remap(existing, value).
assertEquals(4, m.merge("k", 3) { a, b -> a + b })
assertEquals(4, m["k"])
}
@Test
fun mapSnapshotIsDetached() {
val m = ConcurrentMap<String, Int>()
m["a"] = 1
m["b"] = 2
val snap = m.snapshot()
assertEquals(mapOf("a" to 1, "b" to 2), snap)
// Mutating the map after the snapshot must not change the snapshot.
m["c"] = 3
assertEquals(2, snap.size)
assertEquals(3, m.size())
}
@Test
fun setAddContainsSize() {
val s = ConcurrentSet<String>()
assertFalse("x" in s)
assertTrue(s.add("x"))
// Re-adding is a no-op and reports it.
assertFalse(s.add("x"))
assertTrue("x" in s)
assertTrue(s.add("y"))
assertEquals(2, s.size())
}
@Test
fun setSnapshotIsDetached() {
val s = ConcurrentSet<String>()
s.add("a")
val snap = s.snapshot()
s.add("b")
assertEquals(setOf("a"), snap)
assertEquals(2, s.size())
}
}