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. // Custom fetcher that considers tor settings and avoids forwarding.
val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05) val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05)
val namecoinResolver = val namecoinElectrumxClient =
NamecoinNameResolver(
electrumxClient =
ElectrumXClient( ElectrumXClient(
socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() }, socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() },
), )
val namecoinResolver =
NamecoinNameResolver(
electrumxClient = namecoinElectrumxClient,
serverListProvider = { serverListProvider = {
if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) { if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) {
TOR_ELECTRUMX_SERVERS TOR_ELECTRUMX_SERVERS
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.service.namecoin 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.ElectrumXClient
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinLookupCache import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinLookupCache
@@ -43,12 +44,17 @@ class NamecoinNameService(
) { ) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val resolver = NamecoinNameResolver(electrumxClient)
private val cache = NamecoinLookupCache()
// Custom server list (user-configurable) // Custom server list (user-configurable)
@Volatile
private var customServers: List<ElectrumxServer> = emptyList() private var customServers: List<ElectrumxServer> = emptyList()
private val resolver =
NamecoinNameResolver(
electrumxClient = electrumxClient,
serverListProvider = { customServers.ifEmpty { DEFAULT_ELECTRUMX_SERVERS } },
)
private val cache = NamecoinLookupCache()
// ── Public API ───────────────────────────────────────────────────── // ── Public API ─────────────────────────────────────────────────────
/** /**
@@ -97,7 +103,10 @@ class NamecoinNameService(
* *
* Useful for composable UIs that observe resolution state. * 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) val state = MutableStateFlow<NamecoinResolveState>(NamecoinResolveState.Loading)
scope.launch { scope.launch {
try { try {
@@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.User
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.NamecoinLookupException
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -43,12 +44,13 @@ import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.StateFlow
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.map
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
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
@@ -75,27 +77,73 @@ 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 ->
.map { 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) {
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), 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 = val searchResultsUsers =
combine( combine(
@@ -48,6 +48,30 @@ data class ElectrumxServer(
val trustAllCerts: Boolean = false, 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). */ /** Well-known public Namecoin ElectrumX servers (clearnet). */
val DEFAULT_ELECTRUMX_SERVERS = val DEFAULT_ELECTRUMX_SERVERS =
listOf( listOf(
@@ -206,16 +206,43 @@ class NamecoinNameResolver(
// Extended form: "nostr": { "names": {...}, "relays": {...} } // Extended form: "nostr": { "names": {...}, "relays": {...} }
if (nostrField is JsonObject) { if (nostrField is JsonObject) {
val names = nostrField["names"]?.jsonObject ?: return null 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 // Resolve: exact match → "_" root → first entry (root lookups only)
if (!isValidPubkey(pubkey)) return null 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) val relays = extractRelays(nostrField, pubkey)
return NamecoinNostrResult( return NamecoinNostrResult(
pubkey = pubkey.lowercase(), pubkey = pubkey.lowercase(),
relays = relays, relays = relays,
namecoinName = parsed.namecoinName, namecoinName = parsed.namecoinName,
localPart = parsed.localPart, localPart = resolvedLocalPart,
) )
} }
@@ -44,6 +44,7 @@ import java.net.Socket
import java.security.MessageDigest import java.security.MessageDigest
import java.security.SecureRandom import java.security.SecureRandom
import java.security.cert.X509Certificate import java.security.cert.X509Certificate
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import javax.net.SocketFactory import javax.net.SocketFactory
import javax.net.ssl.SSLContext import javax.net.ssl.SSLContext
@@ -83,7 +84,7 @@ class ElectrumXClient(
isLenient = true isLenient = true
} }
private val requestId = AtomicInteger(0) private val requestId = AtomicInteger(0)
private val mutex = Mutex() private val serverMutexes = ConcurrentHashMap<String, Mutex>()
companion object { companion object {
private const val PROTOCOL_VERSION = "1.4" private const val PROTOCOL_VERSION = "1.4"
@@ -121,9 +122,13 @@ class ElectrumXClient(
server: ElectrumxServer = DEFAULT_ELECTRUMX_SERVERS.first(), server: ElectrumxServer = DEFAULT_ELECTRUMX_SERVERS.first(),
): NameShowResult? = ): NameShowResult? =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val mutex = serverMutexes.getOrPut("${server.host}:${server.port}") { Mutex() }
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()
@@ -137,16 +142,28 @@ 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]
* @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 ──────────────────────────────────────────────────────
@@ -175,7 +192,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()
@@ -196,7 +213,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)
} }
} }
@@ -115,6 +115,36 @@ class NamecoinNameResolverTest {
assertEquals("aaaa000000000000000000000000000000000000000000000000000000000001", result!!.pubkey) 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 ──────────────────────────────────── // ── Value format: id/ namespace ────────────────────────────────────
@Test @Test
@@ -209,9 +239,40 @@ class NamecoinNameResolverTest {
// Extended form // Extended form
if (nostrField is kotlinx.serialization.json.JsonObject) { if (nostrField is kotlinx.serialization.json.JsonObject) {
val names = nostrField["names"]?.jsonObject ?: return null 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 // Resolve: exact match → "_" root → first entry (root lookups only)
if (!pubkey.matches(Regex("^[0-9a-fA-F]{64}$"))) return null 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 = val relays =
try { try {
@@ -227,7 +288,7 @@ class NamecoinNameResolverTest {
pubkey = pubkey.lowercase(), pubkey = pubkey.lowercase(),
relays = relays, relays = relays,
namecoinName = namecoinName, namecoinName = namecoinName,
localPart = localPart, localPart = resolvedLocalPart,
) )
} }
return null return null