Merge pull request #1771 from mstrofnone/namecoin-nip05-improvements

fix: address race conditions and improve Namecoin NIP-05 resolution
This commit is contained in:
Vitor Pamplona
2026-03-06 15:13:20 -05:00
committed by GitHub
7 changed files with 220 additions and 32 deletions
@@ -147,12 +147,14 @@ class AppModules(
// Custom fetcher that considers tor settings and avoids forwarding.
val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05)
val namecoinElectrumxClient =
ElectrumXClient(
socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() },
)
val namecoinResolver =
NamecoinNameResolver(
electrumxClient =
ElectrumXClient(
socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() },
),
electrumxClient = namecoinElectrumxClient,
serverListProvider = {
if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) {
TOR_ELECTRUMX_SERVERS
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.namecoin
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinLookupCache
@@ -43,12 +44,17 @@ class NamecoinNameService(
) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val resolver = NamecoinNameResolver(electrumxClient)
private val cache = NamecoinLookupCache()
// Custom server list (user-configurable)
@Volatile
private var customServers: List<ElectrumxServer> = emptyList()
private val resolver =
NamecoinNameResolver(
electrumxClient = electrumxClient,
serverListProvider = { customServers.ifEmpty { DEFAULT_ELECTRUMX_SERVERS } },
)
private val cache = NamecoinLookupCache()
// ── Public API ─────────────────────────────────────────────────────
/**
@@ -97,7 +103,10 @@ class NamecoinNameService(
*
* Useful for composable UIs that observe resolution state.
*/
fun resolveLive(identifier: String): StateFlow<NamecoinResolveState> {
fun resolveLive(
identifier: String,
scope: CoroutineScope = this.scope,
): StateFlow<NamecoinResolveState> {
val state = MutableStateFlow<NamecoinResolveState>(NamecoinResolveState.Loading)
scope.launch {
try {
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinLookupException
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
import kotlinx.coroutines.Dispatchers
@@ -43,12 +44,13 @@ import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
@@ -75,27 +77,73 @@ class SearchBarViewModel(
val searchDataSourceState = SearchQueryState(MutableStateFlow(searchValue), account)
/**
* Resolves Namecoin identifiers (.bit / d/ / id/) via ElectrumX and
* returns the matching [User] from LocalCache, or null.
* Observable state for Namecoin resolution in the search bar.
* 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
.debounce(400)
.distinctUntilChanged()
.filter { NamecoinNameResolver.isNamecoinIdentifier(it) }
.map { term ->
.mapLatest { term ->
if (!NamecoinNameResolver.isNamecoinIdentifier(term)) {
return@mapLatest NamecoinSearchState.Idle
}
try {
val result = Amethyst.instance.namecoinResolver.resolve(term)
if (result != null) {
LocalCache.getOrCreateUser(result.pubkey)
NamecoinSearchState.Resolved(LocalCache.getOrCreateUser(result.pubkey))
} else {
null
NamecoinSearchState.NotFound(term)
}
} catch (e: kotlinx.coroutines.CancellationException) {
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) {
null
NamecoinSearchState.ServersUnreachable
}
}.flowOn(Dispatchers.IO)
.stateIn(viewModelScope, WhileSubscribed(5000), null)
.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)
val searchResultsUsers =
combine(