fix(relay): show relay groups when NIP-11 is unreachable; reconnect on trust change

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J8KBSw6smQRyXLiWHeDsZ8
This commit is contained in:
Claude
2026-07-22 22:35:06 +00:00
parent 7837f6b233
commit abdcd9c39d
3 changed files with 81 additions and 10 deletions
@@ -36,6 +36,18 @@ class Nip11CachedRetriever(
private val relayInformationDocumentCache = LruCache<NormalizedRelayUrl, RetrieveResult?>(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;
@@ -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<NormalizedRelayUrl>? = null
private var lastDmRelays: Set<NormalizedRelayUrl>? = null
private var lastMoneyOpRelays: Set<NormalizedRelayUrl>? = 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,
)
}
}
@@ -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) {