feat: propagate distinct error types from ElectrumX to search UI

Add NamecoinLookupException sealed class to distinguish:
- NameNotFound: name queried successfully but doesn't exist on blockchain
- NameExpired: name exists but expired (>36000 blocks since last update)
- ServersUnreachable: all ElectrumX servers failed with connection errors

Previously, all three cases returned null from nameShowWithFallback,
making it impossible for the UI to show meaningful feedback.

Changes:
- ElectrumxClient.connectAndQuery: throws NameNotFound/NameExpired
  instead of returning null for definitive blockchain answers
- ElectrumxClient.nameShow: lets NamecoinLookupException propagate
  (only catches connection/IO errors as null)
- ElectrumxClient.nameShowWithFallback: short-circuits on NameNotFound/
  NameExpired (no point trying other servers for definitive answers),
  throws ServersUnreachable when all servers fail with connection errors
- SearchBarViewModel: new namecoinSearchState flow with typed states
  (Idle/Loading/Resolved/NotFound/Expired/ServersUnreachable) for UI
  to show loading spinners, resolved profiles, and specific error
  messages. Derived namecoinResolvedUser from this flow.

Discovered while porting to notedeck (damus-io/notedeck#1314).
This commit is contained in:
M
2026-03-06 13:55:41 +11:00
parent ee418bdd0a
commit 050fd3a412
2 changed files with 110 additions and 13 deletions
@@ -34,6 +34,8 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.namecoin.NamecoinNameService
import com.vitorpamplona.quartz.nip05.namecoin.NamecoinLookupException
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
@@ -46,8 +48,8 @@ import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
@@ -75,28 +77,62 @@ class SearchBarViewModel(
val searchDataSourceState = SearchQueryState(MutableStateFlow(searchValue), account) val searchDataSourceState = SearchQueryState(MutableStateFlow(searchValue), account)
/** /**
* Resolves Namecoin identifiers (.bit / d/ / id/) via ElectrumX and * Observable state for Namecoin resolution in the search bar.
* returns the matching [User] from LocalCache, or null. * Allows the UI to show loading spinners, resolved profiles, and
* specific error messages ("name not found" vs "servers unreachable").
*/ */
private val namecoinResolvedUser = sealed class NamecoinSearchState {
data object Idle : NamecoinSearchState()
data object Loading : NamecoinSearchState()
data class Resolved(val user: User) : NamecoinSearchState()
data class NotFound(val name: String) : NamecoinSearchState()
data class Expired(val name: String) : NamecoinSearchState()
data object ServersUnreachable : NamecoinSearchState()
}
/**
* Resolves Namecoin identifiers (.bit / d/ / id/) via ElectrumX.
* Emits typed state so the UI can show loading, resolved profile, or
* specific error messages.
*/
val namecoinSearchState: StateFlow<NamecoinSearchState> =
searchValueFlow searchValueFlow
.debounce(400) .debounce(400)
.distinctUntilChanged() .distinctUntilChanged()
.filter { NamecoinNameResolver.isNamecoinIdentifier(it) }
.mapLatest { term -> .mapLatest { term ->
if (!NamecoinNameResolver.isNamecoinIdentifier(term)) {
return@mapLatest NamecoinSearchState.Idle
}
try { try {
val result = Amethyst.instance.namecoinResolver.resolve(term) val result = Amethyst.instance.namecoinResolver.resolve(term)
if (result != null) { if (result != null) {
LocalCache.getOrCreateUser(result.pubkey) NamecoinSearchState.Resolved(LocalCache.getOrCreateUser(result.pubkey))
} else { } else {
null NamecoinSearchState.NotFound(term)
} }
} catch (e: kotlinx.coroutines.CancellationException) { } catch (e: kotlinx.coroutines.CancellationException) {
throw e throw e
} catch (e: NamecoinLookupException.NameNotFound) {
NamecoinSearchState.NotFound(e.name)
} catch (e: NamecoinLookupException.NameExpired) {
NamecoinSearchState.Expired(e.name)
} catch (e: NamecoinLookupException.ServersUnreachable) {
NamecoinSearchState.ServersUnreachable
} catch (_: Exception) { } catch (_: Exception) {
null NamecoinSearchState.ServersUnreachable
} }
}.flowOn(Dispatchers.IO) }.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, WhileSubscribed(5000), NamecoinSearchState.Idle)
/** Convenience: the resolved user (or null) for combining with local search results. */
private val namecoinResolvedUser =
namecoinSearchState
.map { state ->
when (state) {
is NamecoinSearchState.Resolved -> state.user
else -> null
}
}
.stateIn(viewModelScope, WhileSubscribed(5000), null) .stateIn(viewModelScope, WhileSubscribed(5000), null)
val searchResultsUsers = val searchResultsUsers =
@@ -52,6 +52,48 @@ import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManager import javax.net.ssl.TrustManager
import javax.net.ssl.X509TrustManager import javax.net.ssl.X509TrustManager
/**
* Result of an ElectrumX name_show query.
*
* Maps to the JSON fields returned by Namecoin Core / Electrum-NMC:
* { "name": "d/example", "value": "{...}", "txid": "abc...", "height": 12345, ... }
*/
@Serializable
data class NameShowResult(
val name: String,
val value: String,
val txid: String? = null,
val height: Int? = null,
val expiresIn: Int? = null,
)
/**
* Specific exception types for Namecoin resolution failures.
* Allows callers to distinguish "name doesn't exist" from "servers unreachable".
*/
sealed class NamecoinLookupException(message: String, cause: Throwable? = null) : Exception(message, cause) {
/** The name was queried successfully but does not exist on the blockchain. */
class NameNotFound(val name: String) : NamecoinLookupException("Name not found: $name")
/** The name has expired (>36000 blocks since last update). */
class NameExpired(val name: String) : NamecoinLookupException("Name expired: $name")
/** All ElectrumX servers were unreachable or returned errors. */
class ServersUnreachable(val lastError: Throwable? = null) :
NamecoinLookupException("All ElectrumX servers unreachable", lastError)
}
/**
* Represents a single ElectrumX server endpoint.
*/
data class ElectrumxServer(
val host: String,
val port: Int,
val useSsl: Boolean = true,
/** If true, accept any certificate (self-signed, expired, etc.) */
val trustAllCerts: Boolean = false,
)
/** /**
* Lightweight, query-only ElectrumX client for Namecoin name resolution. * Lightweight, query-only ElectrumX client for Namecoin name resolution.
* *
@@ -126,6 +168,9 @@ class ElectrumXClient(
mutex.withLock { mutex.withLock {
try { try {
connectAndQuery(identifier, server) connectAndQuery(identifier, server)
} catch (e: NamecoinLookupException) {
// Propagate name-not-found and expired — these are definitive answers.
throw e
} catch (e: Exception) { } catch (e: Exception) {
// Log but don't crash — callers handle null gracefully. // Log but don't crash — callers handle null gracefully.
e.printStackTrace() e.printStackTrace()
@@ -140,15 +185,31 @@ class ElectrumXClient(
* @param identifier Full Namecoin name, e.g. "d/example" * @param identifier Full Namecoin name, e.g. "d/example"
* @param servers Ordered server list to try; defaults to [DEFAULT_ELECTRUMX_SERVERS] * @param servers Ordered server list to try; defaults to [DEFAULT_ELECTRUMX_SERVERS]
*/ */
/**
* Try each server in order until one succeeds.
*
* @throws NamecoinLookupException.NameNotFound if the name definitively doesn't exist
* @throws NamecoinLookupException.NameExpired if the name has expired
* @throws NamecoinLookupException.ServersUnreachable if all servers failed with connection errors
*/
override suspend fun nameShowWithFallback( override suspend fun nameShowWithFallback(
identifier: String, identifier: String,
servers: List<ElectrumxServer>, servers: List<ElectrumxServer>,
): NameShowResult? { ): NameShowResult? {
var lastError: Exception? = null
for (server in servers) { for (server in servers) {
try {
val result = nameShow(identifier, server) val result = nameShow(identifier, server)
if (result != null) return result if (result != null) return result
} catch (e: NamecoinLookupException.NameNotFound) {
throw e // Definitive answer from blockchain — no point trying other servers
} catch (e: NamecoinLookupException.NameExpired) {
throw e // Definitive answer
} catch (e: Exception) {
lastError = e // Server error — try next server
} }
return null }
throw NamecoinLookupException.ServersUnreachable(lastError)
} }
// ── internals ────────────────────────────────────────────────────── // ── internals ──────────────────────────────────────────────────────
@@ -177,7 +238,7 @@ class ElectrumXClient(
writer.println(historyReq) writer.println(historyReq)
val historyResponse = reader.readLine() ?: return null val historyResponse = reader.readLine() ?: return null
val historyEntries = parseHistoryResponse(historyResponse) ?: return null val historyEntries = parseHistoryResponse(historyResponse) ?: return null
if (historyEntries.isEmpty()) return null if (historyEntries.isEmpty()) throw NamecoinLookupException.NameNotFound(identifier)
// 4. Get the latest transaction (last entry = most recent update) // 4. Get the latest transaction (last entry = most recent update)
val latestEntry = historyEntries.last() val latestEntry = historyEntries.last()
@@ -198,7 +259,7 @@ class ElectrumXClient(
if (currentHeight != null && height > 0) { if (currentHeight != null && height > 0) {
val blocksSinceUpdate = currentHeight - height val blocksSinceUpdate = currentHeight - height
if (blocksSinceUpdate >= NAME_EXPIRE_DEPTH) { if (blocksSinceUpdate >= NAME_EXPIRE_DEPTH) {
return null // Name has expired throw NamecoinLookupException.NameExpired(identifier)
} }
} }