From abdcd9c39da4b3b3c8bbeca5e5eaddbd2bdff6a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 22:35:06 +0000 Subject: [PATCH] fix(relay): show relay groups when NIP-11 is unreachable; reconnect on trust change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes for the "connected over clearnet, receiving 39000s, but nothing shows" case on a Cloudflare-fronted relay (blocks the plain HTTP NIP-11 GET while serving events over the socket): - Relay group-list screen no longer hard-depends on NIP-11. The display gate needs the relay's `self` key (from NIP-11) to show relay-signed groups; when the NIP-11 fetch fails (FAIL_TO_REACH_SERVER — Cloudflare resets the GET), fall back to the dominant 39000 signer on that relay as its de-facto signer, so its groups render while a stray user-published 39000 (different author) stays filtered. Also re-fetch NIP-11 when the relay is marked Trusted (moved to clearnet), busting the cached over-Tor error (new Nip11CachedRetriever.invalidate). - RelayProxyClientConnector now reconnects the transport-flipped relay immediately when a relay-classification set changes (e.g. marking it Trusted → clearnet). Previously only TorRelaySettings changes counted, so a newly-trusted relay sat out its Tor-earned backoff before re-dialing. Scoped to onlyIfChanged (no resetBackoff), so only the flipped relay skips its delay and the rest of the pool's backoff is untouched. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8 --- .../nip11RelayInfo/Nip11CachedRetriever.kt | 12 +++++ .../relayClient/RelayProxyClientConnector.kt | 35 ++++++++++++++- .../relayGroup/RelayGroupChannelListScreen.kt | 44 +++++++++++++++---- 3 files changed, 81 insertions(+), 10 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11CachedRetriever.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11CachedRetriever.kt index 07a265e947..e97b3a6270 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11CachedRetriever.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11CachedRetriever.kt @@ -36,6 +36,18 @@ class Nip11CachedRetriever( private val relayInformationDocumentCache = LruCache(1000) private val retriever = Nip11Retriever(okHttpClient) + /** + * Drops any cached NIP-11 document (including a cached *error*) for [relay], so the next + * [loadRelayInfo] performs a fresh fetch. Used when the transport to a relay changes — e.g. it's + * marked Trusted to move off Tor onto clearnet — so a doc that failed over the old transport + * (403/timeout) isn't served from cache for the rest of its TTL, which would keep the relay's + * `self` key unknown and hide its relay-signed NIP-29 groups. + */ + fun invalidate(relay: NormalizedRelayUrl) { + relayInformationDocumentCache.remove(relay) + relayInformationEmptyCache.remove(relay) + } + fun trimToSize(maxItems: Int) { relayInformationDocumentCache.trimToSize(maxItems) // relayInformationEmptyCache holds only lightweight display-name+favicon-url placeholders; diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt index 5e93a4dc0b..995870ce21 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.model.torState.TorRelayEvaluation import com.vitorpamplona.amethyst.service.connectivity.ConnectivityStatus import com.vitorpamplona.amethyst.ui.tor.TorServiceStatus import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -85,6 +86,16 @@ class RelayProxyClientConnector( // one relay serve out its delay. private var lastTorSettings: TorRelaySettings? = null + // The relay-classification sets (trusted/DM/money) from the last apply(). A relay moving into or + // out of one of these flips its transport (e.g. marking a relay Trusted moves it off Tor onto + // clearnet) WITHOUT changing TorRelaySettings, so `torPolicyChanged` above misses it and the + // flipped relay would sit out its (now-irrelevant) backoff. We track these so such a relay can + // skip its retry delay on the next reconnect — scoped to onlyIfChanged, so only the relays that + // actually flipped re-dial and the rest of the pool's backoff is left untouched. + private var lastTrustedRelays: Set? = null + private var lastDmRelays: Set? = null + private var lastMoneyOpRelays: Set? = null + @OptIn(FlowPreview::class) val relayServices = combine( @@ -150,6 +161,9 @@ class RelayProxyClientConnector( lastTorSettings = torSettings lastTorConnection = infra.torConnection lastClearConnection = infra.clearConnection + lastTrustedRelays = infra.evaluator.trustedRelayList + lastDmRelays = infra.evaluator.dmRelayList + lastMoneyOpRelays = infra.evaluator.moneyOpRelayList } else -> { @@ -170,12 +184,28 @@ class RelayProxyClientConnector( // relays go through Tor. The relays whose transport flipped must re-dial now. val torPolicyChanged = lastTorSettings != null && torSettings != lastTorSettings + // A relay moved into/out of the trusted/DM/money sets (e.g. marked Trusted to move it + // off Tor onto clearnet). That flips only that relay's transport, not TorRelaySettings, + // so let onlyIfChanged pick out the flipped relay(s) and skip THEIR retry delay — + // without resetBackoff(), so the rest of the pool's backoff is untouched (these sets + // churn while relay lists load, and forgiving the whole pool then would be too much). + val classificationChanged = + lastTrustedRelays != null && + ( + infra.evaluator.trustedRelayList != lastTrustedRelays || + infra.evaluator.dmRelayList != lastDmRelays || + infra.evaluator.moneyOpRelayList != lastMoneyOpRelays + ) + val previousNetworkId = lastNetworkId lastTorConnection = infra.torConnection lastClearConnection = infra.clearConnection lastNetworkId = networkId ?: lastNetworkId lastTorSettings = torSettings + lastTrustedRelays = infra.evaluator.trustedRelayList + lastDmRelays = infra.evaluator.dmRelayList + lastMoneyOpRelays = infra.evaluator.moneyOpRelayList if (networkChanged) { Log.d("ManageRelayServices") { @@ -196,11 +226,12 @@ class RelayProxyClientConnector( Log.d("ManageRelayServices") { "Relay Services have changed, reconnecting relays that need to " + - "(transportChanged=$transportChanged torPolicyChanged=$torPolicyChanged)" + "(transportChanged=$transportChanged torPolicyChanged=$torPolicyChanged " + + "classificationChanged=$classificationChanged)" } client.reconnect( onlyIfChanged = true, - ignoreRetryDelays = freshStart, + ignoreRetryDelays = freshStart || classificationChanged, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt index 3744aa18f3..3331f67c3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/relayGroup/RelayGroupChannelListScreen.kt @@ -69,7 +69,6 @@ import com.vitorpamplona.amethyst.commons.tor.TorType import com.vitorpamplona.amethyst.commons.util.sortedBySnapshot import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.nip11RelayInfo.isRelaySignedRelayGroup -import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.model.nip11RelayInfo.looksLikeNonNip29Relay import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -113,9 +112,21 @@ fun RelayGroupChannelListScreen( RelayGroupsOnRelaySubscription(relay, accountViewModel.dataSources().relayGroupsOnRelay, accountViewModel) + // Trust state drives the Tor→clearnet hint below AND a NIP-11 re-fetch: marking a relay Trusted + // moves it off Tor onto clearnet, so a NIP-11 doc that failed over Tor must be re-fetched — its + // cached error would otherwise keep the relay's `self` unknown for the whole TTL. + val trustedRelays by accountViewModel.account.trustedRelayList.flow + .collectAsStateWithLifecycle() + val isTrusted = relay in trustedRelays + // Warm the relay's NIP-11 so we can tell its genuine (relay-signed) groups from stray - // user-published 39000s that a non-NIP-29 relay may also be storing. - val relayInfo by loadRelayInfo(relay) + // user-published 39000s that a non-NIP-29 relay may also be storing. Re-keyed on trust so a move + // to clearnet re-fetches over the new transport instead of serving the cached over-Tor failure. + val nip11Cache = Amethyst.instance.nip11Cache + val relayInfo by produceState(nip11Cache.getFromCache(relay), relay, isTrusted) { + if (isTrusted) nip11Cache.invalidate(relay) + nip11Cache.loadRelayInfo(relay, onInfo = { value = it }, onError = { _, _, _ -> }) + } // Re-read the relay's channels whenever a group-metadata (kind 39000) event lands in // the cache — driven by LocalCache.observeEvents rather than a timer, so the list @@ -132,9 +143,28 @@ fun RelayGroupChannelListScreen( } } - // Only the relay's own genuine, relay-signed groups (39000 author == the relay's NIP-11 `self`). - // Recomputes as the NIP-11 doc resolves so real groups fill in and fakes stay hidden. - val channels = remember(allChannels, relayInfo) { allChannels.filter { isRelaySignedRelayGroup(it, relayInfo) } } + // Prefer the relay's own genuine, relay-signed groups (39000 author == the NIP-11 `self`). + // Recomputes as the NIP-11 doc resolves so real groups fill in and fakes stay hidden. But if + // NIP-11 is unreachable (e.g. a Cloudflare-fronted relay that resets the plain HTTP GET while + // still serving events over the socket), fall back to the dominant 39000 signer on this relay as + // its de-facto signer — so its relay-signed groups still show while a stray user-published 39000 + // (a different author) stays filtered. + val channels = + remember(allChannels, relayInfo) { + val nip11Known = relayInfo.self != null || relayInfo.supported_nips != null + if (nip11Known) { + allChannels.filter { isRelaySignedRelayGroup(it, relayInfo) } + } else { + val dominantSigner = + allChannels + .mapNotNull { it.event?.pubKey } + .groupingBy { it } + .eachCount() + .maxByOrNull { it.value } + ?.key + if (dominantSigner != null) allChannels.filter { it.event?.pubKey == dominantSigner } else allChannels + } + } // Buzz relays expose no public group directory (membership is server-side), so `channels` above // stays empty for them. When this is a Buzz relay, fold in the membership-scoped channels @@ -153,8 +183,6 @@ fun RelayGroupChannelListScreen( // Relay List (connected over clearnet even while Tor stays on for everything else). val torType by Amethyst.instance.torPrefs.torType .collectAsStateWithLifecycle(TorType.OFF) - val trustedRelays by accountViewModel.account.trustedRelayList.flow - .collectAsStateWithLifecycle() val isOnion = remember(relay) { relay.url.contains(".onion") } var connectTimedOut by remember(relay) { mutableStateOf(false) } LaunchedEffect(relay) {