feat: discovery only shows groups from relays that advertise NIP-29

Stray kind-39000 events published by ordinary users to general relays (e.g.
nostr.wine) were surfacing in the Relay Groups discovery feed as joinable groups
that have no roster and no chat and can't actually be joined — because those
relays don't run NIP-29, they just store the fake metadata like any addressable
event.

A relay that truly runs NIP-29 rejects user-authored 39xxx, so on such a relay
every 39000 is relay-signed and genuine. Gate discovery on that: restrict the
per-relay constraint set to relays whose NIP-11 `supported_nips` advertises 29.
This is the single point both the match test and the REQ-driven feed read, so
non-advertising relays drop out wholesale. The discovery screen warms each
candidate relay's NIP-11 and re-invalidates the feed as support resolves (a
relay whose NIP-11 lands after its 39000s would otherwise stay hidden until a
manual refresh). "My Groups" (the kind-10009 joined list) is unaffected.

Trade-off: a relay that runs NIP-29 but doesn't publish NIP-11 (or omits 29 from
its list) is hidden from discovery until it advertises.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B5MLY4hq5LXJ2D5WeLRyXj
This commit is contained in:
Claude
2026-07-10 14:32:30 +00:00
parent 9a5a278b01
commit 188ba3c609
3 changed files with 83 additions and 2 deletions
@@ -0,0 +1,42 @@
/*
* 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.model.nip11RelayInfo
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* Whether [relay]'s cached NIP-11 document advertises support for [nip] (as a decimal string, e.g.
* "29"). Reads only the in-memory cache — it never blocks on a network fetch — so it returns false
* for a relay whose NIP-11 hasn't been loaded yet. Callers that need the answer to become true must
* warm the document first (e.g. `loadRelayInfo`), then re-evaluate once it resolves.
*/
fun relayAdvertisesNip(
relay: NormalizedRelayUrl,
nip: String,
): Boolean =
Amethyst.instance.nip11Cache
.getFromCache(relay)
.supported_nips
?.any { it == nip } == true
/** NIP-29 (relay-based groups): the relay must run it for its groups to be real. */
fun relayAdvertisesNip29(relay: NormalizedRelayUrl): Boolean = relayAdvertisesNip(relay, "29")
@@ -42,7 +42,9 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -51,6 +53,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
@@ -75,6 +78,7 @@ import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.relayGroupDiscoveryChannelFor
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.dal.toGroupConstraints
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupMyJoinedGroupsSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupWarmupSubscription
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.relayGroup.datasource.RelayGroupsDiscoveryFilterAssemblerSubscription
@@ -215,7 +219,29 @@ private fun WatchAccountForRelayGroupDiscovery(
val joinedGroups by accountViewModel.account.relayGroupList.liveRelayGroupList
.collectAsStateWithLifecycle()
LaunchedEffect(listName, perRelay, joinedGroups) {
// Discovery only shows groups hosted on relays that actually run NIP-29 (a relay that does
// rejects user-authored 39xxx, so its 39000s are all genuine; general relays instead carry stray
// fake ones). The feed decides this from each relay's cached NIP-11 `supported_nips`, so warm
// the candidate relays here and re-invalidate as support is confirmed — otherwise a relay whose
// NIP-11 lands after its 39000s would stay hidden until a manual refresh.
val candidateRelays = remember(perRelay) { perRelay.toGroupConstraints().keys }
var nip29Relays by remember { mutableStateOf(emptySet<NormalizedRelayUrl>()) }
LaunchedEffect(candidateRelays) {
val supported = nip29Relays.toMutableSet()
candidateRelays.forEach { relay ->
Amethyst.instance.nip11Cache.loadRelayInfo(
relay = relay,
onInfo = { info ->
if (info.supported_nips?.any { it == "29" } == true && supported.add(relay)) {
nip29Relays = supported.toSet()
}
},
onError = { _, _, _ -> },
)
}
}
LaunchedEffect(listName, perRelay, joinedGroups, nip29Relays) {
feedContentState.checkKeysInvalidateDataAndSendToTop()
}
}
@@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.TopFilter
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.amethyst.model.nip11RelayInfo.relayAdvertisesNip29
import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter
import com.vitorpamplona.amethyst.ui.dal.sortedByDefaultFeedOrder
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -104,7 +105,19 @@ class RelayGroupDiscoveryFeedFilter(
joined: Set<GroupId>,
): Boolean = channel.groupId in joined || channel.membershipOf(account.userProfile().pubkeyHex).isMember()
private fun constraints(): Map<NormalizedRelayUrl, GroupDiscoveryConstraint> = account.liveRelayGroupsDiscoveryFollowListsPerRelay.value.toGroupConstraints()
/**
* The per-relay constraints, restricted to relays that ADVERTISE NIP-29 in their NIP-11
* `supported_nips`. A relay that truly runs NIP-29 rejects user-authored 39xxx, so on such a
* relay every 39000 is relay-signed and genuine; general relays (nostr.wine, etc.) instead store
* stray user-published 39000s that have no roster and can't be joined. Dropping non-advertising
* relays here filters out that noise for both the match test and the REQ-driven feed at one
* point. Relays whose NIP-11 hasn't loaded yet resolve to false and are warmed by the screen
* ([WatchAccountForRelayGroupDiscovery]), which re-invalidates the feed once support is known.
*/
private fun constraints(): Map<NormalizedRelayUrl, GroupDiscoveryConstraint> =
account.liveRelayGroupsDiscoveryFollowListsPerRelay.value
.toGroupConstraints()
.filterKeys { relayAdvertisesNip29(it) }
override fun feed(): List<Note> {
if (isMine()) return sort(myGroupNotes())