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.
This commit is contained in:
M
2026-03-06 11:35:27 +11:00
parent 0e124f3017
commit ee418bdd0a
6 changed files with 134 additions and 15 deletions
@@ -161,6 +161,9 @@ class AppModules(
} }
}, },
) )
val namecoinNameService =
com.vitorpamplona.amethyst.service.namecoin.NamecoinNameService
.init(namecoinElectrumxClient)
val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver) val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver)
// Application-wide block height request cache // Application-wide block height request cache
@@ -43,11 +43,32 @@ class NamecoinNameService(
) { ) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val resolver = NamecoinNameResolver(electrumxClient) // Custom server list (user-configurable)
@Volatile
private var customServers: List<ElectrumxServer> = emptyList()
private val resolver =
NamecoinNameResolver(
electrumxClient = electrumxClient,
serverListProvider = { customServers.ifEmpty { ElectrumxClient.DEFAULT_SERVERS } },
)
private val cache = NamecoinLookupCache() private val cache = NamecoinLookupCache()
// Custom server list (user-configurable) companion object {
private var customServers: List<ElectrumxServer> = emptyList() @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 ───────────────────────────────────────────────────── // ── Public API ─────────────────────────────────────────────────────
@@ -97,7 +118,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 {
@@ -48,7 +48,7 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
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
@@ -83,7 +83,7 @@ class SearchBarViewModel(
.debounce(400) .debounce(400)
.distinctUntilChanged() .distinctUntilChanged()
.filter { NamecoinNameResolver.isNamecoinIdentifier(it) } .filter { NamecoinNameResolver.isNamecoinIdentifier(it) }
.map { term -> .mapLatest { term ->
try { try {
val result = Amethyst.instance.namecoinResolver.resolve(term) val result = Amethyst.instance.namecoinResolver.resolve(term)
if (result != null) { if (result != null) {
@@ -91,6 +91,8 @@ class SearchBarViewModel(
} else { } else {
null null
} }
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (_: Exception) { } catch (_: Exception) {
null null
} }
@@ -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,6 +122,7 @@ 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)
@@ -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