From ace7a7f476865387753d0df8dbb914f9f80847d8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 9 Jul 2026 21:50:55 +0000 Subject: [PATCH] fix(relay): enforce blocked relays centrally on every REQ/COUNT/publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relay targeting is fully distributed: every feed, loader, finder and broadcast path builds its own relay set and hands it to the shared INostrClient. Only the follow-outbox flows and the top-nav feed filters subtracted the NIP-51 kind:10006 blocked list, so blocked relays still leaked in through the event/thread loaders (FilterMissingEvents / FilterMissingAddressables), the user-metadata finder (pickRelaysToLoadUsers), channel finder, DM targeting, the one-shot fetch helpers, and the publish path (Account.computeRelayListToBroadcast) — none of which consulted the blocked set. Add BlockedRelayFilteringClient, a thin INostrClient decorator that strips the active account's blocked relays from subscribe, count and publish right before they reach the pool. Because the one-shot fetch helpers route through subscribe/count, wrapping the client covers them too. The blocked set is read per-call so account switches and list edits apply with nothing to invalidate. Wire it around the shared app client (blocked set from the logged-in account) and around the per-account crawl client used by Event Sync and Cashu discovery. Add commonTest coverage for the filtering, pass-through, fully-blocked, and per-call-read behaviors. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JNMPdC2eGUwTrt3XkQefuf --- .../com/vitorpamplona/amethyst/AppModules.kt | 19 ++- .../ui/screen/loggedIn/AccountViewModel.kt | 9 +- .../BlockedRelayFilteringClient.kt | 84 ++++++++++ .../BlockedRelayFilteringClientTest.kt | 150 ++++++++++++++++++ 4 files changed, 260 insertions(+), 2 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClient.kt create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClientTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 3dc465b555..8e4b80441d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -26,6 +26,7 @@ import androidx.security.crypto.EncryptedSharedPreferences import coil3.disk.DiskCache import coil3.memory.MemoryCache import com.vitorpamplona.amethyst.commons.model.NoteState +import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResolver import com.vitorpamplona.amethyst.commons.tor.TorSettings @@ -503,7 +504,23 @@ class AppModules( // Provides a relay pool. The caching decoder skips re-parsing EVENT frames // that arrive again via another subscription or relay (14-57% of frames in // production measurements). - val client: INostrClient = NostrClient(websocketBuilder, applicationIOScope, CachingEventDecoder()) + // + // Wrapped in BlockedRelayFilteringClient so the active account's NIP-51 + // kind:10006 blocked relay list is enforced centrally on every REQ, COUNT + // and publish (relay targeting is otherwise distributed across dozens of + // feed/loader/finder/broadcast sites, most of which don't subtract it). + // The blocked set is read per-call from the logged-in account. + val client: INostrClient = + BlockedRelayFilteringClient( + NostrClient(websocketBuilder, applicationIOScope, CachingEventDecoder()), + blockedRelays = { + sessionManager + .loggedInAccount() + ?.blockedRelayList + ?.flow + ?.value ?: emptySet() + }, + ) // Self-heals the "Tor Active but every circuit dead" state the lifecycle watchdogs can't // see (they only arm while Connecting). Watches Tor-routed relay outcomes and, when enough diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index d2b563a1bb..0d3fb5d157 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -47,6 +47,7 @@ import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChann import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.model.observables.CreatedAtComparator import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager +import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastTracker import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.commons.ui.components.UrlPreviewState @@ -313,7 +314,13 @@ class AccountViewModel( // Provides a relay pool. Crawls hit many relays with overlapping // filters, so the duplicate-frame decoder pays off most here. - val newClient = NostrClient(Amethyst.instance.websocketBuilder, customScope, CachingEventDecoder()) + // Wrapped so crawls (Event Sync, Cashu discovery) never contact this + // account's NIP-51 kind:10006 blocked relays either. + val newClient = + BlockedRelayFilteringClient( + NostrClient(Amethyst.instance.websocketBuilder, customScope, CachingEventDecoder()), + blockedRelays = { account.blockedRelayList.flow.value }, + ) // Authenticates with relays (registers itself with the client). RelayAuthenticator( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClient.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClient.kt new file mode 100644 index 0000000000..80e446bc51 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClient.kt @@ -0,0 +1,84 @@ +/* + * 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.amethyst.commons.relayClient + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * A single, central enforcement point for the NIP-51 kind:10006 blocked relay list. + * + * Relay targeting in this app is fully distributed: every feed, loader, finder, and + * publish path builds its own `Map` (for REQ/COUNT) or relay set (for + * publish) and hands it to [INostrClient]. Only a subset of those sites remembered to + * subtract the blocked list, so blocked relays still leaked in through event/thread + * loaders, the user-metadata finder, DM targeting, and the broadcast/publish path. + * + * Rather than patch every selection site (and re-open the same gap with every new + * assembler), this decorator wraps the real client and strips blocked relays from the + * three operations that open a socket — [subscribe], [count] and [publish] — right + * before they reach the pool. Because the one-shot fetch helpers ([fetchAll], + * [fetchFirst], [fetchAllPages], NIP-45 count, …) are extension functions that route + * through [subscribe]/[count], wrapping the client covers them for free. + * + * [blockedRelays] is read on every call so the currently-active account's list always + * applies, with no caching to invalidate on account switch. + * + * Note: the NIP-77 negentropy paths drive a single socket directly through + * [getOrCreateRelay] with a caller-chosen relay; that escape hatch is delegated + * unchanged, so callers that use it must keep filtering blocked relays themselves. + */ +class BlockedRelayFilteringClient( + private val delegate: INostrClient, + private val blockedRelays: () -> Set, +) : INostrClient by delegate { + override fun subscribe( + subId: String, + filters: Map>, + listener: SubscriptionListener?, + ) { + delegate.subscribe(subId, filters.withoutBlocked(), listener) + } + + override fun count( + subId: String, + filters: Map>, + ) { + delegate.count(subId, filters.withoutBlocked()) + } + + override fun publish( + event: Event, + relayList: Set, + ) { + val blocked = blockedRelays() + delegate.publish(event, if (blocked.isEmpty()) relayList else relayList - blocked) + } + + private fun Map>.withoutBlocked(): Map> { + val blocked = blockedRelays() + if (blocked.isEmpty()) return this + return filterKeys { it !in blocked } + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClientTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClientTest.kt new file mode 100644 index 0000000000..4965c0cc40 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/relayClient/BlockedRelayFilteringClientTest.kt @@ -0,0 +1,150 @@ +/* + * 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.amethyst.commons.relayClient + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlin.test.Test +import kotlin.test.assertEquals + +class BlockedRelayFilteringClientTest { + private val good = NormalizedRelayUrl("wss://good.example/") + private val alsoGood = NormalizedRelayUrl("wss://also-good.example/") + private val blocked = NormalizedRelayUrl("wss://blocked.example/") + + private fun event() = + Event( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1000, + kind = 1, + tags = emptyArray(), + content = "", + sig = "c".repeat(64), + ) + + /** Records the relay maps/sets that actually reach the underlying client. */ + private class RecordingClient : INostrClient by EmptyNostrClient() { + var subscribedFilters: Map>? = null + var countFilters: Map>? = null + var publishedRelays: Set? = null + + override fun subscribe( + subId: String, + filters: Map>, + listener: SubscriptionListener?, + ) { + subscribedFilters = filters + } + + override fun count( + subId: String, + filters: Map>, + ) { + countFilters = filters + } + + override fun publish( + event: Event, + relayList: Set, + ) { + publishedRelays = relayList + } + } + + @Test + fun subscribeDropsBlockedRelaysKeepsOthers() { + val inner = RecordingClient() + val client = BlockedRelayFilteringClient(inner) { setOf(blocked) } + + client.subscribe( + "sub", + mapOf(good to listOf(Filter()), blocked to listOf(Filter()), alsoGood to listOf(Filter())), + null, + ) + + assertEquals(setOf(good, alsoGood), inner.subscribedFilters?.keys) + } + + @Test + fun countDropsBlockedRelays() { + val inner = RecordingClient() + val client = BlockedRelayFilteringClient(inner) { setOf(blocked) } + + client.count("cnt", mapOf(good to listOf(Filter()), blocked to listOf(Filter()))) + + assertEquals(setOf(good), inner.countFilters?.keys) + } + + @Test + fun publishDropsBlockedRelays() { + val inner = RecordingClient() + val client = BlockedRelayFilteringClient(inner) { setOf(blocked) } + + client.publish(event(), setOf(good, blocked, alsoGood)) + + assertEquals(setOf(good, alsoGood), inner.publishedRelays) + } + + @Test + fun emptyBlockListPassesEverythingThrough() { + val inner = RecordingClient() + val client = BlockedRelayFilteringClient(inner) { emptySet() } + + val filters = mapOf(good to listOf(Filter()), blocked to listOf(Filter())) + client.subscribe("sub", filters, null) + client.publish(event(), setOf(good, blocked)) + + assertEquals(setOf(good, blocked), inner.subscribedFilters?.keys) + assertEquals(setOf(good, blocked), inner.publishedRelays) + } + + @Test + fun blockingEveryRelayYieldsEmptyTargets() { + val inner = RecordingClient() + val client = BlockedRelayFilteringClient(inner) { setOf(good, blocked) } + + client.subscribe("sub", mapOf(good to listOf(Filter()), blocked to listOf(Filter())), null) + client.publish(event(), setOf(good, blocked)) + + assertEquals(emptySet(), inner.subscribedFilters?.keys) + assertEquals(emptySet(), inner.publishedRelays) + } + + @Test + fun blockSetIsReadPerCallSoLaterChangesApply() { + val inner = RecordingClient() + var blockedSet = emptySet() + val client = BlockedRelayFilteringClient(inner) { blockedSet } + + client.subscribe("sub", mapOf(good to listOf(Filter()), blocked to listOf(Filter())), null) + assertEquals(setOf(good, blocked), inner.subscribedFilters?.keys) + + // user blocks a relay after the client was built + blockedSet = setOf(blocked) + client.subscribe("sub", mapOf(good to listOf(Filter()), blocked to listOf(Filter())), null) + assertEquals(setOf(good), inner.subscribedFilters?.keys) + } +}