Merge pull request #3512 from vitorpamplona/claude/nostr-blocked-relays-review-7m5o3t

Enforce blocked relays centrally via decorator client
This commit is contained in:
Vitor Pamplona
2026-07-09 18:12:23 -04:00
committed by GitHub
4 changed files with 260 additions and 2 deletions
@@ -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
@@ -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(
@@ -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<relay, filters>` (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<NormalizedRelayUrl>,
) : INostrClient by delegate {
override fun subscribe(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
) {
delegate.subscribe(subId, filters.withoutBlocked(), listener)
}
override fun count(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
) {
delegate.count(subId, filters.withoutBlocked())
}
override fun publish(
event: Event,
relayList: Set<NormalizedRelayUrl>,
) {
val blocked = blockedRelays()
delegate.publish(event, if (blocked.isEmpty()) relayList else relayList - blocked)
}
private fun Map<NormalizedRelayUrl, List<Filter>>.withoutBlocked(): Map<NormalizedRelayUrl, List<Filter>> {
val blocked = blockedRelays()
if (blocked.isEmpty()) return this
return filterKeys { it !in blocked }
}
}
@@ -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<NormalizedRelayUrl, List<Filter>>? = null
var countFilters: Map<NormalizedRelayUrl, List<Filter>>? = null
var publishedRelays: Set<NormalizedRelayUrl>? = null
override fun subscribe(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
) {
subscribedFilters = filters
}
override fun count(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
) {
countFilters = filters
}
override fun publish(
event: Event,
relayList: Set<NormalizedRelayUrl>,
) {
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<NormalizedRelayUrl>()
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)
}
}