* 'main' of https://github.com/vitorpamplona/amethyst: (35 commits)
  New Crowdin translations by GitHub Action
  fix chess tests
  Replace subsequent checks with '!isNullOrEmpty()' call
  use forEach: no intermediate list created.
  Use filterIsInstance to avoid dual iteration
  Lambda argument should be moved out of parentheses
  replace null checks with Elvis
  merge call chain with flatMap (slight performance improvement)
  New Crowdin translations by GitHub Action
  New Crowdin translations by GitHub Action
  update cz, pt, de, sv
  was this meant to be withContext?
  add names to boolean params
  remove unused imports
  Explicit type arguments can be inferred
  remove redundant modifiers
  correct package name
  If-Null return/break/... foldable to '?:'
  Declaration has type inferred from a platform call, which can lead to unchecked nullability issues. Specify type explicitly as nullable or non-nullable.
  Declaration has type inferred from a platform call, which can lead to unchecked nullability issues. Specify type explicitly as nullable or non-nullable.
  ...

# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/ReactionsSettingsScreen.kt
This commit is contained in:
Vitor Pamplona
2026-03-07 16:47:16 -05:00
144 changed files with 807 additions and 370 deletions
@@ -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