Merge pull request #1771 from mstrofnone/namecoin-nip05-improvements
fix: address race conditions and improve Namecoin NIP-05 resolution
This commit is contained in:
@@ -147,12 +147,14 @@ class AppModules(
|
||||
// Custom fetcher that considers tor settings and avoids forwarding.
|
||||
val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05)
|
||||
|
||||
val namecoinResolver =
|
||||
NamecoinNameResolver(
|
||||
electrumxClient =
|
||||
val namecoinElectrumxClient =
|
||||
ElectrumXClient(
|
||||
socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() },
|
||||
),
|
||||
)
|
||||
|
||||
val namecoinResolver =
|
||||
NamecoinNameResolver(
|
||||
electrumxClient = namecoinElectrumxClient,
|
||||
serverListProvider = {
|
||||
if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) {
|
||||
TOR_ELECTRUMX_SERVERS
|
||||
|
||||
+13
-4
@@ -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 {
|
||||
|
||||
+58
-10
@@ -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(
|
||||
|
||||
+24
@@ -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(
|
||||
|
||||
+31
-4
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+21
-4
@@ -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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+65
-4
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user