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(
@@ -48,6 +48,30 @@ data class ElectrumxServer(
val trustAllCerts: Boolean = false,
)
/**
* 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)
}
/** Well-known public Namecoin ElectrumX servers (clearnet). */
val DEFAULT_ELECTRUMX_SERVERS =
listOf(
@@ -206,16 +206,43 @@ class NamecoinNameResolver(
// Extended form: "nostr": { "names": {...}, "relays": {...} }
if (nostrField is JsonObject) {
val names = nostrField["names"]?.jsonObject ?: return null
val pubkeyElem = names[parsed.localPart] ?: names["_"] // fall back to root
val pubkey = (pubkeyElem as? JsonPrimitive)?.content ?: return null
if (!isValidPubkey(pubkey)) return null
// Resolve: exact match → "_" root → first entry (root lookups only)
val resolvedLocalPart: String
val pubkey: String
val exactMatch = names[parsed.localPart]
val rootMatch = names["_"]
val firstEntry = if (parsed.localPart == "_") names.entries.firstOrNull() else null
when {
exactMatch is JsonPrimitive && isValidPubkey(exactMatch.content) -> {
resolvedLocalPart = parsed.localPart
pubkey = exactMatch.content
}
rootMatch is JsonPrimitive && isValidPubkey(rootMatch.content) -> {
resolvedLocalPart = "_"
pubkey = rootMatch.content
}
firstEntry != null && firstEntry.value is JsonPrimitive &&
isValidPubkey((firstEntry.value as JsonPrimitive).content) -> {
resolvedLocalPart = firstEntry.key
pubkey = (firstEntry.value as JsonPrimitive).content
}
else -> {
return null
}
}
val relays = extractRelays(nostrField, pubkey)
return NamecoinNostrResult(
pubkey = pubkey.lowercase(),
relays = relays,
namecoinName = parsed.namecoinName,
localPart = parsed.localPart,
localPart = resolvedLocalPart,
)
}
@@ -44,6 +44,7 @@ import java.net.Socket
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.cert.X509Certificate
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import javax.net.SocketFactory
import javax.net.ssl.SSLContext
@@ -83,7 +84,7 @@ class ElectrumXClient(
isLenient = true
}
private val requestId = AtomicInteger(0)
private val mutex = Mutex()
private val serverMutexes = ConcurrentHashMap<String, Mutex>()
companion object {
private const val PROTOCOL_VERSION = "1.4"
@@ -121,9 +122,13 @@ class ElectrumXClient(
server: ElectrumxServer = DEFAULT_ELECTRUMX_SERVERS.first(),
): NameShowResult? =
withContext(Dispatchers.IO) {
val mutex = serverMutexes.getOrPut("${server.host}:${server.port}") { Mutex() }
mutex.withLock {
try {
connectAndQuery(identifier, server)
} catch (e: NamecoinLookupException) {
// Propagate name-not-found and expired — these are definitive answers.
throw e
} catch (e: Exception) {
// Log but don't crash — callers handle null gracefully.
e.printStackTrace()
@@ -137,16 +142,28 @@ class ElectrumXClient(
*
* @param identifier Full Namecoin name, e.g. "d/example"
* @param servers Ordered server list to try; defaults to [DEFAULT_ELECTRUMX_SERVERS]
* @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(
identifier: String,
servers: List<ElectrumxServer>,
): NameShowResult? {
var lastError: Exception? = null
for (server in servers) {
val result = nameShow(identifier, server)
if (result != null) return result
try {
val result = nameShow(identifier, server)
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 ──────────────────────────────────────────────────────
@@ -175,7 +192,7 @@ class ElectrumXClient(
writer.println(historyReq)
val historyResponse = reader.readLine() ?: 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)
val latestEntry = historyEntries.last()
@@ -196,7 +213,7 @@ class ElectrumXClient(
if (currentHeight != null && height > 0) {
val blocksSinceUpdate = currentHeight - height
if (blocksSinceUpdate >= NAME_EXPIRE_DEPTH) {
return null // Name has expired
throw NamecoinLookupException.NameExpired(identifier)
}
}
@@ -115,6 +115,36 @@ class NamecoinNameResolverTest {
assertEquals("aaaa000000000000000000000000000000000000000000000000000000000001", result!!.pubkey)
}
@Test
fun `root lookup falls back to first entry when no underscore key`() {
val value = """{
"nostr": {
"names": {
"m": "6cdebccabda1dfa058ab85352a79509b592b2bdfa0370325e28ec1cb4f18667d"
}
}
}"""
val result = extractNostrFromValue(value, "d/testls", "_")
assertNotNull(result)
assertEquals("6cdebccabda1dfa058ab85352a79509b592b2bdfa0370325e28ec1cb4f18667d", result!!.pubkey)
assertEquals("m", result.localPart)
}
@Test
fun `non-root lookup does NOT fall back to first entry`() {
val value = """{
"nostr": {
"names": {
"m": "6cdebccabda1dfa058ab85352a79509b592b2bdfa0370325e28ec1cb4f18667d"
}
}
}"""
val result = extractNostrFromValue(value, "d/testls", "alice")
assertNull(result)
}
// ── Value format: id/ namespace ────────────────────────────────────
@Test
@@ -209,9 +239,40 @@ class NamecoinNameResolverTest {
// Extended form
if (nostrField is kotlinx.serialization.json.JsonObject) {
val names = nostrField["names"]?.jsonObject ?: return null
val pubkeyElem = names[localPart] ?: names["_"] ?: return null
val pubkey = (pubkeyElem as? kotlinx.serialization.json.JsonPrimitive)?.content ?: return null
if (!pubkey.matches(Regex("^[0-9a-fA-F]{64}$"))) return null
// Resolve: exact match → "_" root → first entry (root lookups only)
val resolvedLocalPart: String
val pubkey: String
val exactMatch = names[localPart]
val rootMatch = names["_"]
val firstEntry = if (localPart == "_") names.entries.firstOrNull() else null
when {
exactMatch is kotlinx.serialization.json.JsonPrimitive &&
exactMatch.content.matches(Regex("^[0-9a-fA-F]{64}$")) -> {
resolvedLocalPart = localPart
pubkey = exactMatch.content
}
rootMatch is kotlinx.serialization.json.JsonPrimitive &&
rootMatch.content.matches(Regex("^[0-9a-fA-F]{64}$")) -> {
resolvedLocalPart = "_"
pubkey = rootMatch.content
}
firstEntry != null && firstEntry.value is kotlinx.serialization.json.JsonPrimitive &&
(firstEntry.value as kotlinx.serialization.json.JsonPrimitive)
.content
.matches(Regex("^[0-9a-fA-F]{64}$")) -> {
resolvedLocalPart = firstEntry.key
pubkey = (firstEntry.value as kotlinx.serialization.json.JsonPrimitive).content
}
else -> {
return null
}
}
val relays =
try {
@@ -227,7 +288,7 @@ class NamecoinNameResolverTest {
pubkey = pubkey.lowercase(),
relays = relays,
namecoinName = namecoinName,
localPart = localPart,
localPart = resolvedLocalPart,
)
}
return null