diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt index 955335ef68..699a303f6e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/RelayCommands.kt @@ -40,7 +40,11 @@ import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.RelayFeedsListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayFacet import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent +import com.vitorpamplona.quartz.nip65RelayList.addFacet +import com.vitorpamplona.quartz.nip65RelayList.applyFacet +import com.vitorpamplona.quartz.nip65RelayList.setFacet import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType import com.vitorpamplona.quartz.nip66RelayMonitor.reachability.RelayProber @@ -220,9 +224,10 @@ object RelayCommands { /** The two facet-nouns that edit the read/write markers of kind:10002. */ private enum class Facet( val noun: String, + val rw: AdvertisedRelayFacet, ) { - OUTBOX("outbox"), - INBOX("inbox"), + OUTBOX("outbox", AdvertisedRelayFacet.WRITE), + INBOX("inbox", AdvertisedRelayFacet.READ), } // ------------------------------------------------------------------ @@ -459,7 +464,7 @@ object RelayCommands { "add", "remove", "rm" -> { val present = verb == "add" val url = urlArg(args) ?: return Output.invalidRelayUrl(args.positional(0, "url")) - val changed = mutateNip65(ctx, self) { applyFacet(it, url, facet, present) } + val changed = mutateNip65(ctx, self) { it.applyFacet(url, facet.rw, present) } Output.emit( mapOf( "noun" to facet.noun, @@ -479,7 +484,7 @@ object RelayCommands { if (parsed.isEmpty()) return Output.error("bad_args", "set needs at least one URL; use `relay ${facet.noun} clear` to empty it") parsed } - mutateNip65(ctx, self) { facetSet(it, relays, facet) } + mutateNip65(ctx, self) { it.setFacet(relays, facet.rw) } Output.emit(mapOf("noun" to facet.noun, "kind" to AdvertisedRelayListEvent.KIND, "relays" to facetUrls(ctx, self, facet))) } "list" -> Output.emit(mapOf("noun" to facet.noun, "kind" to AdvertisedRelayListEvent.KIND, "relays" to facetUrls(ctx, self, facet))) @@ -549,7 +554,7 @@ object RelayCommands { // nip65 as read+write (both). changed["nip65"] = if (add) { - mutateNip65(ctx, self) { applyFacet(applyFacet(it, url, Facet.OUTBOX, true), url, Facet.INBOX, true) } + mutateNip65(ctx, self) { it.addFacet(url, AdvertisedRelayFacet.WRITE).addFacet(url, AdvertisedRelayFacet.READ) } } else { mutateNip65(ctx, self) { infos -> infos.filterNot { it.relayUrl.url == url.url } } } @@ -661,68 +666,6 @@ object RelayCommands { self: HexKey, ): List = ctx.relaysOf(self)?.relays().orEmpty() - /** Read/write flags for one relay, split out from [AdvertisedRelayType]. */ - private data class RW( - val read: Boolean, - val write: Boolean, - ) - - private fun AdvertisedRelayType.rw() = RW(isRead(), isWrite()) - - private fun RW.toTypeOrNull(): AdvertisedRelayType? = - when { - read && write -> AdvertisedRelayType.BOTH - read -> AdvertisedRelayType.READ - write -> AdvertisedRelayType.WRITE - else -> null - } - - /** - * Toggle one [facet] on/off for [url] within the kind:10002 entry list, - * applying the NIP-65 merge rules: turning a facet on merges into `both` - * when the other facet is set; turning the last facet off drops the relay. - * Order is preserved. - */ - private fun applyFacet( - infos: List, - url: NormalizedRelayUrl, - facet: Facet, - present: Boolean, - ): List { - val urls = LinkedHashMap() - val flags = LinkedHashMap() - for (i in infos) { - urls[i.relayUrl.url] = i.relayUrl - flags[i.relayUrl.url] = i.type.rw() - } - val cur = flags[url.url] ?: RW(read = false, write = false) - val next = if (facet == Facet.OUTBOX) cur.copy(write = present) else cur.copy(read = present) - if (next.read || next.write) { - urls[url.url] = url - flags[url.url] = next - } else { - urls.remove(url.url) - flags.remove(url.url) - } - return flags.entries.map { AdvertisedRelayInfo(urls[it.key]!!, it.value.toTypeOrNull()!!) } - } - - /** Make exactly [targets] carry [facet], demoting/removing any relay that currently does but shouldn't. */ - private fun facetSet( - infos: List, - targets: List, - facet: Facet, - ): List { - val keep = targets.map { it.url }.toSet() - var result = infos - for (i in infos) { - val has = if (facet == Facet.OUTBOX) i.type.isWrite() else i.type.isRead() - if (has && i.relayUrl.url !in keep) result = applyFacet(result, i.relayUrl, facet, present = false) - } - for (u in targets) result = applyFacet(result, u, facet, present = true) - return result - } - /** Read, transform, and (only if it changed) re-sign + store the kind:10002. Returns whether it changed. */ private suspend fun mutateNip65( ctx: Context, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListMutations.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListMutations.kt new file mode 100644 index 0000000000..2f9659168b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListMutations.kt @@ -0,0 +1,119 @@ +/* + * 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.nip65RelayList + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType + +/** + * One dimension of a NIP-65 kind:10002 entry's read/write marker. + * + * [AdvertisedRelayType] is the *combined* marker stored on the tag + * (`READ`, `WRITE`, or `BOTH`); a facet is a single flag of it, which is + * what UIs edit ("use this relay as an outbox" toggles [WRITE]). + */ +enum class AdvertisedRelayFacet { + READ, + WRITE, +} + +/** Read/write flags for one relay, split out from [AdvertisedRelayType]. */ +private data class RW( + val read: Boolean, + val write: Boolean, +) + +private fun AdvertisedRelayType.rw() = RW(isRead(), isWrite()) + +private fun RW.toTypeOrNull(): AdvertisedRelayType? = + when { + read && write -> AdvertisedRelayType.BOTH + read -> AdvertisedRelayType.READ + write -> AdvertisedRelayType.WRITE + else -> null + } + +/** + * Toggle one [facet] on/off for [url] within a kind:10002 entry list, + * applying the NIP-65 merge rules: + * + * - turning a facet **on** merges into `BOTH` when the other facet is + * already set (adding a write marker to a read-only relay promotes it + * to `BOTH`); adding to an absent relay creates a single-facet entry; + * - turning a facet **off** on a `BOTH` relay demotes it to the other + * facet; turning the **last** facet off drops the relay entirely. + * + * Entry order is preserved; the receiver is not modified. + */ +fun List.applyFacet( + url: NormalizedRelayUrl, + facet: AdvertisedRelayFacet, + present: Boolean, +): List { + val urls = LinkedHashMap() + val flags = LinkedHashMap() + for (i in this) { + urls[i.relayUrl.url] = i.relayUrl + flags[i.relayUrl.url] = i.type.rw() + } + val cur = flags[url.url] ?: RW(read = false, write = false) + val next = if (facet == AdvertisedRelayFacet.WRITE) cur.copy(write = present) else cur.copy(read = present) + if (next.read || next.write) { + urls[url.url] = url + flags[url.url] = next + } else { + urls.remove(url.url) + flags.remove(url.url) + } + return flags.entries.map { AdvertisedRelayInfo(urls[it.key]!!, it.value.toTypeOrNull()!!) } +} + +/** Adds [facet] to [url], promoting an existing entry to `BOTH` when it already carries the other facet. */ +fun List.addFacet( + url: NormalizedRelayUrl, + facet: AdvertisedRelayFacet, +): List = applyFacet(url, facet, present = true) + +/** Removes [facet] from [url], demoting a `BOTH` entry to the other facet or dropping the relay when it was the last one. */ +fun List.removeFacet( + url: NormalizedRelayUrl, + facet: AdvertisedRelayFacet, +): List = applyFacet(url, facet, present = false) + +/** + * Makes exactly [targets] carry [facet], demoting (or removing, per the + * [applyFacet] merge rules) any relay that currently carries it but + * shouldn't. Relays only carrying the other facet are left untouched. + */ +fun List.setFacet( + targets: List, + facet: AdvertisedRelayFacet, +): List { + val keep = targets.map { it.url }.toSet() + var result = this + for (i in this) { + val has = if (facet == AdvertisedRelayFacet.WRITE) i.type.isWrite() else i.type.isRead() + if (has && i.relayUrl.url !in keep) result = result.applyFacet(i.relayUrl, facet, present = false) + } + for (u in targets) result = result.applyFacet(u, facet, present = true) + return result +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListMutationsTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListMutationsTest.kt new file mode 100644 index 0000000000..770b9fd9d8 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip65RelayList/AdvertisedRelayListMutationsTest.kt @@ -0,0 +1,100 @@ +/* + * 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.nip65RelayList + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayInfo +import com.vitorpamplona.quartz.nip65RelayList.tags.AdvertisedRelayType +import kotlin.test.Test +import kotlin.test.assertEquals + +class AdvertisedRelayListMutationsTest { + fun norm(str: String) = RelayUrlNormalizer.normalizeOrNull(str)!! + + val relay1 = norm("wss://relay1.com") + val relay2 = norm("wss://relay2.com") + + fun List.keys() = map { it.relayUrl.url to it.type } + + @Test + fun addWriteToReadOnlyPromotesToBoth() { + val before = listOf(AdvertisedRelayInfo(relay1, AdvertisedRelayType.READ)) + + val after = before.addFacet(relay1, AdvertisedRelayFacet.WRITE) + + assertEquals(listOf(relay1.url to AdvertisedRelayType.BOTH), after.keys()) + } + + @Test + fun removeWriteFromBothDemotesToRead() { + val before = listOf(AdvertisedRelayInfo(relay1, AdvertisedRelayType.BOTH)) + + val after = before.removeFacet(relay1, AdvertisedRelayFacet.WRITE) + + assertEquals(listOf(relay1.url to AdvertisedRelayType.READ), after.keys()) + } + + @Test + fun removeLastFacetDropsTheRelay() { + val before = + listOf( + AdvertisedRelayInfo(relay1, AdvertisedRelayType.WRITE), + AdvertisedRelayInfo(relay2, AdvertisedRelayType.BOTH), + ) + + val after = before.removeFacet(relay1, AdvertisedRelayFacet.WRITE) + + assertEquals(listOf(relay2.url to AdvertisedRelayType.BOTH), after.keys()) + } + + @Test + fun addToAbsentRelayCreatesSingleFacetEntry() { + val before = listOf(AdvertisedRelayInfo(relay1, AdvertisedRelayType.BOTH)) + + val afterRead = before.addFacet(relay2, AdvertisedRelayFacet.READ) + val afterWrite = before.addFacet(relay2, AdvertisedRelayFacet.WRITE) + + assertEquals( + listOf(relay1.url to AdvertisedRelayType.BOTH, relay2.url to AdvertisedRelayType.READ), + afterRead.keys(), + ) + assertEquals( + listOf(relay1.url to AdvertisedRelayType.BOTH, relay2.url to AdvertisedRelayType.WRITE), + afterWrite.keys(), + ) + } + + @Test + fun setFacetDemotesUnlistedAndAddsTargets() { + val before = + listOf( + AdvertisedRelayInfo(relay1, AdvertisedRelayType.BOTH), + AdvertisedRelayInfo(relay2, AdvertisedRelayType.WRITE), + ) + + val after = before.setFacet(listOf(relay2), AdvertisedRelayFacet.WRITE) + + assertEquals( + listOf(relay1.url to AdvertisedRelayType.READ, relay2.url to AdvertisedRelayType.WRITE), + after.keys(), + ) + } +}