Moves electrum client to the jvmAndroid scope

This commit is contained in:
Vitor Pamplona
2026-03-04 10:00:33 -05:00
parent 193561c336
commit 470b1680d0
10 changed files with 146 additions and 101 deletions
@@ -68,6 +68,10 @@ import com.vitorpamplona.quartz.nip03Timestamp.VerificationStateCache
import com.vitorpamplona.quartz.nip03Timestamp.ots.OtsBlockHeightCache
import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client
import com.vitorpamplona.quartz.nip05DnsIdentifiers.OkHttpNip05Fetcher
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.TOR_ELECTRUMX_SERVERS
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
@@ -143,23 +147,20 @@ class AppModules(
// Custom fetcher that considers tor settings and avoids forwarding.
val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05)
val namecoinElectrumxClient =
com.vitorpamplona.quartz.nip05.namecoin.ElectrumxClient(
ElectrumXClient(
socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() },
)
val namecoinResolver =
com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver(
NamecoinNameResolver(
electrumxClient = namecoinElectrumxClient,
serverListProvider = {
if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) {
com.vitorpamplona.quartz.nip05.namecoin.ElectrumxClient.TOR_SERVERS
TOR_ELECTRUMX_SERVERS
} else {
com.vitorpamplona.quartz.nip05.namecoin.ElectrumxClient.DEFAULT_SERVERS
DEFAULT_ELECTRUMX_SERVERS
}
},
)
val namecoinNameService =
com.vitorpamplona.amethyst.service.namecoin.NamecoinNameService
.init(namecoinElectrumxClient)
val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver)
// Application-wide block height request cache
@@ -20,11 +20,11 @@
*/
package com.vitorpamplona.amethyst.service.namecoin
import com.vitorpamplona.quartz.nip05.namecoin.ElectrumxClient
import com.vitorpamplona.quartz.nip05.namecoin.ElectrumxServer
import com.vitorpamplona.quartz.nip05.namecoin.NamecoinLookupCache
import com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05.namecoin.NamecoinNostrResult
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinLookupCache
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -39,7 +39,7 @@ import kotlinx.coroutines.launch
* into Amethyst's existing `ServiceManager` infrastructure.
*/
class NamecoinNameService(
electrumxClient: ElectrumxClient = ElectrumxClient(),
electrumxClient: ElectrumXClient,
) {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -49,22 +49,6 @@ class NamecoinNameService(
// Custom server list (user-configurable)
private var customServers: List<ElectrumxServer> = 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 ─────────────────────────────────────────────────────
/**
@@ -29,14 +29,14 @@ import androidx.compose.ui.focus.FocusRequester
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.Amethyst
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.amethyst.service.relayClient.searchCommand.SearchQueryState
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
import com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
@@ -85,7 +85,7 @@ class SearchBarViewModel(
.filter { NamecoinNameResolver.isNamecoinIdentifier(it) }
.map { term ->
try {
val result = NamecoinNameService.getInstance().resolve(term)
val result = Amethyst.instance.namecoinResolver.resolve(term)
if (result != null) {
LocalCache.getOrCreateUser(result.pubkey)
} else {
@@ -21,7 +21,7 @@
package com.vitorpamplona.quartz.nip05DnsIdentifiers
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import kotlinx.coroutines.CancellationException
data class Nip05KeyInfo(
@@ -0,0 +1,70 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin
import kotlinx.serialization.Serializable
/**
* 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,
)
/**
* 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,
)
/** Well-known public Namecoin ElectrumX servers (clearnet). */
val DEFAULT_ELECTRUMX_SERVERS =
listOf(
ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true),
ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true),
ElectrumxServer("46.229.238.187", 57002, useSsl = true, trustAllCerts = true),
)
/** Tor-preferred server list: onion primary, clearnet fallback. */
val TOR_ELECTRUMX_SERVERS =
listOf(
ElectrumxServer(
"i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion",
50002,
useSsl = true,
trustAllCerts = true,
),
ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true),
ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true),
)
@@ -0,0 +1,28 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin
interface IElectrumXClient {
suspend fun nameShowWithFallback(
identifier: String,
servers: List<ElectrumxServer>,
): NameShowResult?
}
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip05.namecoin
package com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin
import androidx.collection.LruCache
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip05.namecoin
package com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
@@ -50,9 +50,9 @@ data class NamecoinNostrResult(
* here instead of to the HTTP-based NIP-05 path.
*/
class NamecoinNameResolver(
private val electrumxClient: ElectrumxClient = ElectrumxClient(),
private val electrumxClient: IElectrumXClient,
private val lookupTimeoutMs: Long = 20_000L,
private val serverListProvider: () -> List<ElectrumxServer> = { ElectrumxClient.DEFAULT_SERVERS },
private val serverListProvider: () -> List<ElectrumxServer> = { DEFAULT_ELECTRUMX_SERVERS },
) {
private val json =
Json {
@@ -18,15 +18,16 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip05.namecoin
package com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.ListSerializer
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
@@ -41,6 +42,8 @@ import java.io.PrintWriter
import java.net.InetSocketAddress
import java.net.Socket
import java.security.MessageDigest
import java.security.SecureRandom
import java.security.cert.X509Certificate
import java.util.concurrent.atomic.AtomicInteger
import javax.net.SocketFactory
import javax.net.ssl.SSLContext
@@ -48,32 +51,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,
)
/**
* 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.
*
@@ -95,11 +72,11 @@ data class ElectrumxServer(
* val result = client.nameShow("d/example", server)
* ```
*/
class ElectrumxClient(
class ElectrumXClient(
private val connectTimeoutMs: Long = 10_000L,
private val readTimeoutMs: Long = 15_000L,
private val socketFactory: () -> SocketFactory = { SocketFactory.getDefault() },
) {
) : IElectrumXClient {
private val json =
Json {
ignoreUnknownKeys = true
@@ -109,27 +86,6 @@ class ElectrumxClient(
private val mutex = Mutex()
companion object {
/** Well-known public Namecoin ElectrumX servers (clearnet). */
val DEFAULT_SERVERS =
listOf(
ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true),
ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true),
ElectrumxServer("46.229.238.187", 57002, useSsl = true, trustAllCerts = true),
)
/** Tor-preferred server list: onion primary, clearnet fallback. */
val TOR_SERVERS =
listOf(
ElectrumxServer(
"i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion",
50002,
useSsl = true,
trustAllCerts = true,
),
ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true),
ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true),
)
private const val PROTOCOL_VERSION = "1.4"
/**
@@ -162,7 +118,7 @@ class ElectrumxClient(
*/
suspend fun nameShow(
identifier: String,
server: ElectrumxServer = DEFAULT_SERVERS.first(),
server: ElectrumxServer = DEFAULT_ELECTRUMX_SERVERS.first(),
): NameShowResult? =
withContext(Dispatchers.IO) {
mutex.withLock {
@@ -180,11 +136,11 @@ class ElectrumxClient(
* Try each server in order until one succeeds.
*
* @param identifier Full Namecoin name, e.g. "d/example"
* @param servers Ordered server list to try; defaults to [DEFAULT_SERVERS]
* @param servers Ordered server list to try; defaults to [DEFAULT_ELECTRUMX_SERVERS]
*/
suspend fun nameShowWithFallback(
override suspend fun nameShowWithFallback(
identifier: String,
servers: List<ElectrumxServer> = DEFAULT_SERVERS,
servers: List<ElectrumxServer>,
): NameShowResult? {
for (server in servers) {
val result = nameShow(identifier, server)
@@ -329,7 +285,7 @@ class ElectrumxClient(
private fun parseHistoryResponse(raw: String): List<Pair<String, Int>>? {
val envelope = json.parseToJsonElement(raw).jsonObject
val error = envelope["error"]
if (error != null && error !is kotlinx.serialization.json.JsonNull) return null
if (error != null && error !is JsonNull) return null
val result = envelope["result"]?.jsonArray ?: return null
return result.mapNotNull { entry ->
@@ -354,7 +310,7 @@ class ElectrumxClient(
): NameShowResult? {
val envelope = json.parseToJsonElement(raw).jsonObject
val error = envelope["error"]
if (error != null && error !is kotlinx.serialization.json.JsonNull) return null
if (error != null && error !is JsonNull) return null
val result = envelope["result"]?.jsonObject ?: return null
val vouts = result["vout"]?.jsonArray ?: return null
@@ -505,20 +461,20 @@ class ElectrumxClient(
arrayOf<TrustManager>(
object : X509TrustManager {
override fun checkClientTrusted(
chain: Array<java.security.cert.X509Certificate>,
chain: Array<X509Certificate>,
authType: String,
) {}
override fun checkServerTrusted(
chain: Array<java.security.cert.X509Certificate>,
chain: Array<X509Certificate>,
authType: String,
) {}
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
override fun getAcceptedIssuers(): Array<X509Certificate> = arrayOf()
},
)
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, trustAllCerts, java.security.SecureRandom())
sslContext.init(null, trustAllCerts, SecureRandom())
return sslContext.socketFactory
}
@@ -535,8 +491,8 @@ class ElectrumxClient(
put(
"params",
json.encodeToJsonElement(
kotlinx.serialization.builtins.ListSerializer(
kotlinx.serialization.json.JsonElement
ListSerializer(
JsonElement
.serializer(),
),
params.map {
@@ -20,6 +20,8 @@
*/
package com.vitorpamplona.quartz.nip05.namecoin
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
@@ -267,7 +269,11 @@ class NamecoinNameResolverTest {
} catch (_: Exception) {
emptyList()
}
return NamecoinNostrResult(pubkey = pubkey.lowercase(), relays = relays, namecoinName = namecoinName)
return NamecoinNostrResult(
pubkey = pubkey.lowercase(),
relays = relays,
namecoinName = namecoinName,
)
}
}
return null