From ee418bdd0a555c26f96159d552bab5f24a77ec36 Mon Sep 17 00:00:00 2001 From: M Date: Fri, 6 Mar 2026 11:35:27 +1100 Subject: [PATCH 1/5] fix: address race conditions and improve Namecoin NIP-05 resolution Fixes discovered while porting Namecoin NIP-05 to Primal Android (PrimalHQ/primal-android-app#934): 1. Race condition: stale intermediate lookups overwrite final result - Changed map to mapLatest in SearchBarViewModel so that previous in-flight ElectrumX lookups are cancelled when the query changes - Without this, typing 'd/testls' character by character causes stale results from 'd/test', 'd/testl' etc. to arrive after the correct 'd/testls' result and overwrite it with null 2. CancellationException swallowed in catch block - mapLatest cancels previous coroutines via CancellationException - The generic catch was catching it and emitting null, wiping the valid result. Now re-throws CancellationException 3. Root lookup fails when names object has no underscore key - extractFromDomainValue tried names[localPart] then names[underscore] which is the same thing for root lookups - Now falls back to first available entry for root lookups when no underscore key exists - Non-root lookups still fail correctly (no false matches) 4. Global Mutex serializes all lookups - Single Mutex in ElectrumxClient blocked unrelated lookups to different servers. A 25s timeout on one server stalled everything - Now uses per-server mutexes via ConcurrentHashMap 5. customServers set but never read - NamecoinNameService.setCustomServers() stored the list but the resolver was constructed with the default serverListProvider - Now wires customServers through to the resolver 6. resolveLive uses orphaned CoroutineScope - Now accepts an optional external scope parameter so callers can tie resolution to their own lifecycle Adds 2 new tests: root fallback to first entry, non-root no-fallback. --- .../com/vitorpamplona/amethyst/AppModules.kt | 3 + .../service/namecoin/NamecoinNameService.kt | 32 +++++++-- .../loggedIn/search/SearchBarViewModel.kt | 6 +- .../namecoin/NamecoinNameResolver.kt | 35 ++++++++-- .../namecoin/ElectrumXClient.kt | 4 +- .../namecoin/NamecoinNameResolverTest.kt | 69 +++++++++++++++++-- 6 files changed, 134 insertions(+), 15 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index c05fa392c..7def92c09 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -161,6 +161,9 @@ class AppModules( } }, ) + val namecoinNameService = + com.vitorpamplona.amethyst.service.namecoin.NamecoinNameService + .init(namecoinElectrumxClient) val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver) // Application-wide block height request cache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt index 7f4bf2c37..348521b12 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt @@ -43,11 +43,32 @@ class NamecoinNameService( ) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val resolver = NamecoinNameResolver(electrumxClient) + // Custom server list (user-configurable) + @Volatile + private var customServers: List = emptyList() + + private val resolver = + NamecoinNameResolver( + electrumxClient = electrumxClient, + serverListProvider = { customServers.ifEmpty { ElectrumxClient.DEFAULT_SERVERS } }, + ) private val cache = NamecoinLookupCache() - // Custom server list (user-configurable) - private var customServers: List = emptyList() + companion object { + @Volatile + private var instance: NamecoinNameService? = null + + fun getInstance(): NamecoinNameService = + instance ?: throw IllegalStateException( + "NamecoinNameService not initialized. Call init() first.", + ) + + fun init(electrumxClient: ElectrumxClient): NamecoinNameService = + synchronized(this) { + instance?.let { return it } + NamecoinNameService(electrumxClient).also { instance = it } + } + } // ── Public API ───────────────────────────────────────────────────── @@ -97,7 +118,10 @@ class NamecoinNameService( * * Useful for composable UIs that observe resolution state. */ - fun resolveLive(identifier: String): StateFlow { + fun resolveLive( + identifier: String, + scope: CoroutineScope = this.scope, + ): StateFlow { val state = MutableStateFlow(NamecoinResolveState.Loading) scope.launch { try { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 8855dd149..280411842 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -48,7 +48,7 @@ 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 @@ -83,7 +83,7 @@ class SearchBarViewModel( .debounce(400) .distinctUntilChanged() .filter { NamecoinNameResolver.isNamecoinIdentifier(it) } - .map { term -> + .mapLatest { term -> try { val result = Amethyst.instance.namecoinResolver.resolve(term) if (result != null) { @@ -91,6 +91,8 @@ class SearchBarViewModel( } else { null } + } catch (e: kotlinx.coroutines.CancellationException) { + throw e } catch (_: Exception) { null } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt index b63735330..e9f4e07d0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt @@ -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, ) } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt index 9043a1d8f..477b93733 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt @@ -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() companion object { private const val PROTOCOL_VERSION = "1.4" @@ -121,6 +122,7 @@ 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) diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt index 42488cbcb..52822c49c 100644 --- a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt @@ -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 From 050fd3a4124a2480418a7fae873b0b130914ecf2 Mon Sep 17 00:00:00 2001 From: M Date: Fri, 6 Mar 2026 13:55:41 +1100 Subject: [PATCH 2/5] 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). --- .../loggedIn/search/SearchBarViewModel.kt | 52 +++++++++++--- .../namecoin/ElectrumXClient.kt | 71 +++++++++++++++++-- 2 files changed, 110 insertions(+), 13 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 280411842..84ce76f89 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -34,6 +34,8 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache 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.ui.dal.DefaultFeedOrder 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.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 @@ -75,28 +77,62 @@ 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 = searchValueFlow .debounce(400) .distinctUntilChanged() - .filter { NamecoinNameResolver.isNamecoinIdentifier(it) } .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), 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 = diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt index 477b93733..54486b552 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt @@ -52,6 +52,48 @@ import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManager 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. * @@ -126,6 +168,9 @@ class ElectrumXClient( 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() @@ -140,15 +185,31 @@ class ElectrumXClient( * @param identifier Full Namecoin name, e.g. "d/example" * @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( identifier: String, servers: List, ): 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 ────────────────────────────────────────────────────── @@ -177,7 +238,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() @@ -198,7 +259,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) } } From 4ecfbbb844a338a32fa31fa065421dd137b68cf7 Mon Sep 17 00:00:00 2001 From: M Date: Fri, 6 Mar 2026 14:13:25 +1100 Subject: [PATCH 3/5] fix: add missing StateFlow import, merge duplicate KDoc blocks, and fix rebase conflicts - Add missing 'import kotlinx.coroutines.flow.StateFlow' in SearchBarViewModel.kt - Merge duplicate KDoc blocks on nameShowWithFallback in ElectrumXClient.kt - Move NamecoinLookupException to ElectrumXServer.kt (avoid redeclaration) - Remove duplicate NameShowResult/ElectrumxServer from ElectrumXClient.kt - Fix ElectrumxClient -> ElectrumXClient renames in NamecoinNameService - Extract namecoinElectrumxClient in AppModules.kt for NamecoinNameService.init() - Update package paths from nip05.namecoin to nip05DnsIdentifiers.namecoin --- .../com/vitorpamplona/amethyst/AppModules.kt | 10 ++-- .../service/namecoin/NamecoinNameService.kt | 5 +- .../loggedIn/search/SearchBarViewModel.kt | 24 +++++++--- .../settings/ReactionsSettingsScreen.kt | 6 +-- .../namecoin/ElectrumXServer.kt | 24 ++++++++++ .../namecoin/ElectrumXClient.kt | 46 ------------------- 6 files changed, 52 insertions(+), 63 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 7def92c09..5b74e99c5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt index 348521b12..5c780e5b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt @@ -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 @@ -50,7 +51,7 @@ class NamecoinNameService( private val resolver = NamecoinNameResolver( electrumxClient = electrumxClient, - serverListProvider = { customServers.ifEmpty { ElectrumxClient.DEFAULT_SERVERS } }, + serverListProvider = { customServers.ifEmpty { DEFAULT_ELECTRUMX_SERVERS } }, ) private val cache = NamecoinLookupCache() @@ -63,7 +64,7 @@ class NamecoinNameService( "NamecoinNameService not initialized. Call init() first.", ) - fun init(electrumxClient: ElectrumxClient): NamecoinNameService = + fun init(electrumxClient: ElectrumXClient): NamecoinNameService = synchronized(this) { instance?.let { return it } NamecoinNameService(electrumxClient).also { instance = it } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 84ce76f89..942b66df8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -34,10 +34,9 @@ import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache 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.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 @@ -45,6 +44,7 @@ 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 @@ -83,10 +83,21 @@ class SearchBarViewModel( */ 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 class Resolved( + val user: User, + ) : NamecoinSearchState() + + data class NotFound( + val name: String, + ) : NamecoinSearchState() + + data class Expired( + val name: String, + ) : NamecoinSearchState() + data object ServersUnreachable : NamecoinSearchState() } @@ -132,8 +143,7 @@ class SearchBarViewModel( is NamecoinSearchState.Resolved -> state.user else -> null } - } - .stateIn(viewModelScope, WhileSubscribed(5000), null) + }.stateIn(viewModelScope, WhileSubscribed(5000), null) val searchResultsUsers = combine( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ReactionsSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ReactionsSettingsScreen.kt index 968b6beff..15b5e583c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ReactionsSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ReactionsSettingsScreen.kt @@ -238,16 +238,14 @@ private fun ReactionRowItemCard( .fillMaxWidth() .onGloballyPositioned { coordinates -> onMeasured(coordinates.size.height.toFloat()) - } - .graphicsLayer { + }.graphicsLayer { translationY = dragOffsetY shadowElevation = elevation if (isDragging) { scaleX = 1.02f scaleY = 1.02f } - } - .padding(vertical = 8.dp), + }.padding(vertical = 8.dp), ) { Row( modifier = Modifier.fillMaxWidth(), diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt index 67e093e07..f31edfe7d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXServer.kt @@ -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( diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt index 54486b552..c56dd72b1 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/ElectrumXClient.kt @@ -52,48 +52,6 @@ import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManager 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. * @@ -184,10 +142,6 @@ class ElectrumXClient( * * @param identifier Full Namecoin name, e.g. "d/example" * @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 From 24a03b5d20468f4a2059e757323f70467daaeb3e Mon Sep 17 00:00:00 2001 From: mstrofnone Date: Sat, 7 Mar 2026 00:38:58 +1100 Subject: [PATCH 4/5] remove unused singleton companion object from NamecoinNameService MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The companion object (instance, getInstance, init) was dead code — nothing outside the class referenced it. Koin handles instantiation via AppModules. --- .../service/namecoin/NamecoinNameService.kt | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt index 5c780e5b4..d2240151b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt @@ -55,22 +55,6 @@ class NamecoinNameService( ) private val cache = NamecoinLookupCache() - companion object { - @Volatile - private var instance: NamecoinNameService? = null - - fun getInstance(): NamecoinNameService = - instance ?: throw IllegalStateException( - "NamecoinNameService not initialized. Call init() first.", - ) - - fun init(electrumxClient: ElectrumXClient): NamecoinNameService = - synchronized(this) { - instance?.let { return it } - NamecoinNameService(electrumxClient).also { instance = it } - } - } - // ── Public API ───────────────────────────────────────────────────── /** From a817a2cc793dd01bf8691c93cede71967ec3bc47 Mon Sep 17 00:00:00 2001 From: mstrofnone Date: Sat, 7 Mar 2026 00:49:05 +1100 Subject: [PATCH 5/5] remove dead namecoinNameService declaration from AppModules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NamecoinNameService.init() singleton was the only consumer of the companion object removed in the previous commit. The namecoinNameService val itself was never referenced — namecoinResolver is what's wired into Nip05Client. --- .../src/main/java/com/vitorpamplona/amethyst/AppModules.kt | 3 --- 1 file changed, 3 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 5b74e99c5..f07a8b3a6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -163,9 +163,6 @@ class AppModules( } }, ) - val namecoinNameService = - com.vitorpamplona.amethyst.service.namecoin.NamecoinNameService - .init(namecoinElectrumxClient) val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver) // Application-wide block height request cache