From d9d7b44e914790370b6d9251e3a83ccf25d5bd44 Mon Sep 17 00:00:00 2001 From: m Date: Thu, 28 May 2026 06:01:54 +1000 Subject: [PATCH] feat(desktop): Namecoin Core RPC backend + composite fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings Amethyst Desktop to feature parity with Android for the Namecoin resolution backend stack landed in #3056 / #3068. Three pieces: - DesktopNamecoinPreferences now persists backend, namecoinCoreRpc, fallbackToCustomElectrumx and fallbackToDefaultElectrumx (KEY_BACKEND / KEY_CORE_RPC / KEY_FALLBACK_*), mirroring NamecoinSharedPreferences. Mutators are non-suspend because java.util.prefs is synchronous, unlike Android's coroutine-backed DataStore. The Jackson mapper now rejects unknown properties on read so kotlinx `@Serializable` computed getters (e.g. NamecoinCoreRpcConfig.isUsable) round-trip cleanly through java.util.prefs. - DesktopNamecoinNameService takes an optional OkHttpClient provider, lazily constructs a NamecoinCoreRpcClient when supplied, and builds a fresh CompositeNamecoinBackend per lookup based on current NamecoinSettings. Same shape as AppModules#buildNamecoinBackend. Exposes the underlying RPC client (rpcClient) and a probeCoreRpc(cfg) helper for the Settings Test RPC button. - NamecoinSettingsSection gains a backend radio selector, a Core RPC subform (URL / username / password / Save / Test RPC) with a TOFU cert-pin AlertDialog mirroring Android's NamecoinCoreRpcSection, and a fallback toggles section. The same KEY_PINNED_CERTS list is shared with both ElectrumXClient and NamecoinCoreRpcClient via setDynamicCerts(...), matching Android's behaviour where both backends consume one trust store. - Main.kt wires DesktopHttpClient.currentClient() in as the Core RPC HTTP provider so .onion RPC URLs flow through the existing Tor routing without extra plumbing, and propagates the new mutators to the Settings UI. - Extends DesktopNamecoinPreferencesTest with 8 new cases covering default state, backend round-trip, Core RPC URL/user/pass/pin-flag round-trip, fallback toggles, reset clearing, and a full multi-field round-trip across a fresh preferences instance. Verification on the canonical workspace clone: - ./gradlew :commons:jvmTest --tests *Namecoin* — BUILD SUCCESSFUL - ./gradlew :amethyst:compilePlayDebugKotlin — BUILD SUCCESSFUL - ./gradlew :desktopApp:compileKotlin :desktopApp:test — BUILD SUCCESSFUL - ./gradlew :amethyst:testPlayDebugUnitTest --tests *Namecoin* — BUILD SUCCESSFUL - ./gradlew :amethyst:spotlessCheck :commons:spotlessCheck :desktopApp:spotlessCheck — BUILD SUCCESSFUL --- .../vitorpamplona/amethyst/desktop/Main.kt | 52 +- .../namecoin/DesktopNamecoinNameService.kt | 142 ++++- .../namecoin/DesktopNamecoinPreferences.kt | 123 +++- .../ui/settings/NamecoinSettingsSection.kt | 583 +++++++++++++++++- .../DesktopNamecoinPreferencesTest.kt | 119 ++++ 5 files changed, 1003 insertions(+), 16 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 9787f46b65..fef81a722b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -969,13 +969,21 @@ fun App( val account = accountState as AccountState.LoggedIn val nwcConnection by accountManager.nwcConnection.collectAsState() - // Lazy-load Namecoin services + // Lazy-load Namecoin services. The Core RPC HTTP + // client is sourced from the Tor-aware DesktopHttpClient + // singleton so .onion RPC URLs route through the + // user's Tor settings without extra plumbing. val namecoinPreferences = remember { DesktopNamecoinPreferences() } val namecoinService = remember { DesktopNamecoinNameService( preferencesProvider = { namecoinPreferences.current }, pinnedCertsProvider = { namecoinPreferences.loadPinnedCerts() }, + coreRpcHttpClientProvider = { _ -> + com.vitorpamplona.amethyst.desktop.network + .DesktopHttpClient + .currentClient() + }, ) } @@ -1682,17 +1690,53 @@ fun RelaySettingsScreen( { pem -> namecoinPrefsHere.addPinnedCert(pem) // Apply immediately so the next lookup uses the new pin. + // The same list is shared with the Namecoin Core RPC + // client when present, mirroring Android's behaviour + // where both backends consume one trust store. namecoinScope.launch { try { - svc.client.setDynamicCerts( - namecoinPrefsHere.loadPinnedCerts(), - ) + val pins = namecoinPrefsHere.loadPinnedCerts() + svc.client.setDynamicCerts(pins) + svc.rpcClient?.setDynamicCerts(pins) } catch (_: Exception) { // Best-effort — persisted, will apply on next restart. } } } }, + onSetBackend = { backend -> + namecoinScope.launch { namecoinPrefsHere.setBackend(backend) } + }, + onSetCoreRpcConfig = { cfg -> + namecoinScope.launch { + namecoinPrefsHere.setCoreRpcConfig(cfg) + // Push the new config into the live client so the + // next lookup uses it without restarting the app. + namecoinServiceHere?.rpcClient?.setConfig(cfg) + } + }, + onSetFallbackToCustomElectrumx = { enabled -> + namecoinScope.launch { + namecoinPrefsHere.setFallbackToCustomElectrumx(enabled) + } + }, + onSetFallbackToDefaultElectrumx = { enabled -> + namecoinScope.launch { + namecoinPrefsHere.setFallbackToDefaultElectrumx(enabled) + } + }, + onTestCoreRpc = + namecoinServiceHere?.let { svc -> + { cfg -> + svc.probeCoreRpc(cfg) + ?: com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin + .RpcProbeResult( + success = false, + elapsedMs = 0, + error = "Namecoin Core RPC client not available", + ) + } + }, ) Spacer(Modifier.height(24.dp)) HorizontalDivider() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinNameService.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinNameService.kt index 0b9f899ed4..1d1f832628 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinNameService.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinNameService.kt @@ -22,30 +22,56 @@ package com.vitorpamplona.amethyst.desktop.service.namecoin import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinResolveState import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinSettings +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.CompositeNamecoinBackend import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxNameBackend +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.IElectrumXClient +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NameShowResult +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinBackend +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcClient +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcConfig import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinLookupCache +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameBackend import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNostrResult import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.RpcProbeResult import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch +import okhttp3.OkHttpClient import javax.net.SocketFactory /** * Desktop application-level singleton for Namecoin name resolution. * * Same functionality as the Android `NamecoinNameService` but instantiated - * directly (no Koin/Hilt DI). Uses plain JVM sockets (no Tor support on - * Desktop yet). + * directly (no Koin/Hilt DI). Uses plain JVM sockets for ElectrumX (no + * Tor support on Desktop yet) and an injectable [OkHttpClient] provider + * for Namecoin Core JSON-RPC traffic. + * + * Backend dispatch mirrors Android's `AppModules.kt#buildNamecoinBackend` + * exactly: for every lookup the service builds a fresh + * [CompositeNamecoinBackend] from the current [NamecoinSettings] so that + * changes in the Settings UI take effect immediately, without restarting + * the app. */ class DesktopNamecoinNameService( private val preferencesProvider: () -> NamecoinSettings = { NamecoinSettings.DEFAULT }, pinnedCertsProvider: () -> List = { emptyList() }, + /** + * OkHttpClient provider for Namecoin Core JSON-RPC traffic. The lambda + * receives the target URL so a Tor-routing implementation can pick + * the right client (direct vs. proxy) per request. When `null`, the + * Core RPC backend isn't built and resolution behaves exactly as + * before (ElectrumX only). + */ + private val coreRpcHttpClientProvider: ((String) -> OkHttpClient)? = null, ) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) @@ -68,6 +94,30 @@ class DesktopNamecoinNameService( } } + /** + * Long-lived Namecoin Core JSON-RPC client. Lazy because callers that + * never enable the Core RPC backend shouldn't pay for it. Bootstraps + * the active config + pinned trust store from the same shared + * preferences entry the ElectrumX client uses, exactly like Android's + * `AppModules.namecoinCoreRpcClient` path. + */ + private val namecoinCoreRpcClient: NamecoinCoreRpcClient? = + coreRpcHttpClientProvider?.let { provider -> + NamecoinCoreRpcClient(httpClientForUrl = provider).also { client -> + scope.launch { + try { + client.setConfig(preferencesProvider().namecoinCoreRpc) + val pinned = pinnedCertsProvider() + if (pinned.isNotEmpty()) { + client.setDynamicCerts(pinned) + } + } catch (_: Exception) { + // Non-fatal — user can re-pin via Settings. + } + } + } + } + /** * Expose the underlying ElectrumX client so the Settings UI can push a * newly-accepted cert in via [ElectrumXClient.setDynamicCerts] without @@ -76,12 +126,86 @@ class DesktopNamecoinNameService( */ val client: ElectrumXClient get() = electrumxClient + /** + * Expose the underlying Namecoin Core RPC client so the Settings UI + * can call [NamecoinCoreRpcClient.probe] and push newly-accepted + * pins in via [NamecoinCoreRpcClient.setDynamicCerts]. `null` when + * the service was constructed without a Core RPC HTTP provider + * (Core RPC backend disabled). + */ + val rpcClient: NamecoinCoreRpcClient? get() = namecoinCoreRpcClient + + /** + * Compose the active Namecoin lookup backend based on user settings. + * + * Builds a fresh composite per call so settings changes take effect + * immediately. Same shape as Android's `AppModules.buildNamecoinBackend()`, + * minus the Tor-server-list branch which Desktop doesn't have yet. + */ + private fun buildNamecoinBackend(): IElectrumXClient { + val settings = preferencesProvider() + val custom = settings.toElectrumxServers() + val defaults = DEFAULT_ELECTRUMX_SERVERS + + val customExBackend = + custom?.let { servers -> ElectrumxNameBackend(electrumxClient) { servers } } + val defaultExBackend = ElectrumxNameBackend(electrumxClient) { defaults } + + return when (settings.backend) { + NamecoinBackend.NAMECOIN_CORE_RPC -> { + val rpc = namecoinCoreRpcClient + if (rpc == null) { + // No HTTP provider plumbed in — degrade gracefully to + // ElectrumX so the user isn't stranded. Mirrors the + // "primary unreachable" path inside CompositeNamecoinBackend. + buildElectrumxComposite(customExBackend, defaultExBackend, settings) + } else { + // Refresh client config in case the user just saved it. + rpc.setConfig(settings.namecoinCoreRpc) + CompositeNamecoinBackend( + primary = rpc, + customElectrumx = customExBackend, + defaultElectrumx = defaultExBackend, + policy = settings.toFallbackPolicy(), + isPrimaryCoreRpc = true, + ) + } + } + NamecoinBackend.ELECTRUMX -> buildElectrumxComposite(customExBackend, defaultExBackend, settings) + } + } + + private fun buildElectrumxComposite( + customExBackend: NamecoinNameBackend?, + defaultExBackend: NamecoinNameBackend, + settings: NamecoinSettings, + ): IElectrumXClient { + // Custom servers first (if any). If the user only has the public + // defaults configured, primary == defaultElectrumx and the + // fallback toggle is moot. + val primary: NamecoinNameBackend = customExBackend ?: defaultExBackend + return CompositeNamecoinBackend( + primary = primary, + customElectrumx = null, + defaultElectrumx = if (customExBackend != null) defaultExBackend else null, + policy = settings.toFallbackPolicy(), + isPrimaryCoreRpc = false, + ) + } + private val resolver = NamecoinNameResolver( - electrumxClient = electrumxClient, + electrumxClient = + object : IElectrumXClient { + override suspend fun nameShowWithFallback( + identifier: String, + servers: List, + ): NameShowResult? = buildNamecoinBackend().nameShowWithFallback(identifier, servers) + }, serverListProvider = { - val settings = preferencesProvider() - settings.toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS + // Kept for compatibility with NamecoinNameResolver's API; the + // composite backend ignores this and consults settings directly. + preferencesProvider().toElectrumxServers() ?: DEFAULT_ELECTRUMX_SERVERS }, ) @@ -156,6 +280,14 @@ class DesktopNamecoinNameService( return state } + /** + * Probe a candidate Namecoin Core RPC config (URL + creds) without + * mutating the persisted client config. Used by the Settings + * "Test RPC" button. Returns null when no Core RPC HTTP provider + * was plumbed in — callers should hide the Test RPC UI in that case. + */ + suspend fun probeCoreRpc(cfg: NamecoinCoreRpcConfig): RpcProbeResult? = namecoinCoreRpcClient?.probe(cfg) + /** * Clear the resolution cache. */ diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferences.kt index 2237b86934..abbf27fa4a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferences.kt @@ -20,10 +20,16 @@ */ package com.vitorpamplona.amethyst.desktop.service.namecoin +import com.fasterxml.jackson.annotation.JsonAutoDetect +import com.fasterxml.jackson.annotation.PropertyAccessor +import com.fasterxml.jackson.databind.DeserializationFeature +import com.fasterxml.jackson.databind.SerializationFeature import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.fasterxml.jackson.module.kotlin.readValue import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinSettings import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinBackend +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcConfig import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import java.util.prefs.Preferences @@ -37,6 +43,18 @@ import java.util.prefs.Preferences * The current settings are available synchronously via [settings] (a * [StateFlow]) and can be read in non-suspend contexts (e.g. in a * `serverListProvider` lambda). + * + * Backend / Core RPC / fallback fields mirror the Android + * `NamecoinSharedPreferences` API exactly, except the mutators are + * non-suspend here because [java.util.prefs.Preferences] is synchronous — + * unlike Android's coroutine-backed DataStore. + * + * Note on credential storage: the Core RPC password lives in plain + * `java.util.prefs` (alongside the URL/username), exactly as Android + * stores it in DataStore. Both are user-readable on disk; we accept + * that trade-off because the alternative (system keychain) would + * require platform-specific plumbing that isn't worth it for an + * already-local secret the user typed in themselves. */ class DesktopNamecoinPreferences( private val prefs: Preferences = @@ -44,12 +62,41 @@ class DesktopNamecoinPreferences( DesktopNamecoinPreferences::class.java, ), ) { - private val mapper = jacksonObjectMapper() + /** + * Jackson mapper configured to round-trip kotlinx `@Serializable` data + * classes that ship computed `is*` properties (e.g. + * [NamecoinCoreRpcConfig.isUsable]). Without these tweaks Jackson + * writes `isUsable` on the way out but fails on the way back in + * because the class has no matching setter, which silently nukes + * the persisted Core RPC config. + * + * - Field-only visibility → ignore Kotlin's computed getters. + * - FAIL_ON_UNKNOWN_PROPERTIES=false → belt-and-braces in case a + * future quartz release adds another computed property. + */ + private val mapper = + jacksonObjectMapper().apply { + setVisibility( + serializationConfig.defaultVisibilityChecker + .withFieldVisibility(JsonAutoDetect.Visibility.ANY) + .withGetterVisibility(JsonAutoDetect.Visibility.NONE) + .withIsGetterVisibility(JsonAutoDetect.Visibility.NONE) + .withSetterVisibility(JsonAutoDetect.Visibility.NONE) + .withCreatorVisibility(JsonAutoDetect.Visibility.ANY), + ) + setVisibility(PropertyAccessor.FIELD, JsonAutoDetect.Visibility.ANY) + disable(SerializationFeature.FAIL_ON_EMPTY_BEANS) + disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES) + } companion object { private const val KEY_ENABLED = "namecoin.enabled" private const val KEY_CUSTOM_SERVERS = "namecoin.customServers" private const val KEY_PINNED_CERTS = "namecoin.pinnedCerts" + private const val KEY_BACKEND = "namecoin.backend" + private const val KEY_CORE_RPC = "namecoin.coreRpc" + private const val KEY_FALLBACK_CUSTOM_EX = "namecoin.fallback.customElectrumx" + private const val KEY_FALLBACK_DEFAULT_EX = "namecoin.fallback.defaultElectrumx" } private val _settings = MutableStateFlow(loadFromDisk()) @@ -83,6 +130,43 @@ class DesktopNamecoinPreferences( persist(updated) } + /** + * Change the primary resolution backend (ElectrumX vs Namecoin Core RPC). + * Mirrors `NamecoinSharedPreferences.setBackend` on Android. + */ + suspend fun setBackend(backend: NamecoinBackend) { + val updated = current.copy(backend = backend) + persist(updated) + } + + /** + * Persist the user's Namecoin Core RPC connection details. + * Mirrors `NamecoinSharedPreferences.setCoreRpcConfig` on Android. + */ + suspend fun setCoreRpcConfig(cfg: NamecoinCoreRpcConfig) { + val updated = current.copy(namecoinCoreRpc = cfg) + persist(updated) + } + + /** + * Toggle fallback from Core RPC to custom ElectrumX servers when the + * primary backend can't be reached. Only meaningful when + * `backend == NAMECOIN_CORE_RPC`. + */ + suspend fun setFallbackToCustomElectrumx(enabled: Boolean) { + val updated = current.copy(fallbackToCustomElectrumx = enabled) + persist(updated) + } + + /** + * Toggle fallback to the hardcoded public ElectrumX defaults. Applies + * to both backends — see [NamecoinSettings.fallbackToDefaultElectrumx]. + */ + suspend fun setFallbackToDefaultElectrumx(enabled: Boolean) { + val updated = current.copy(fallbackToDefaultElectrumx = enabled) + persist(updated) + } + suspend fun reset() { persist(NamecoinSettings.DEFAULT) clearPinnedCerts() @@ -97,7 +181,11 @@ class DesktopNamecoinPreferences( * [com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumXClient] * via `setDynamicCerts(...)` so it takes effect immediately. * - * Mirrors the Android API in `NamecoinSharedPreferences`. + * The same store is shared with the Namecoin Core RPC client — both + * `ElectrumXClient` and `NamecoinCoreRpcClient` accept + * `setDynamicCerts(...)` and only consume the certs that actually + * match their own connection. This matches Android's + * `NamecoinSharedPreferences` behaviour exactly. */ fun addPinnedCert(pem: String) { if (pem.isBlank()) return @@ -143,6 +231,10 @@ class DesktopNamecoinPreferences( KEY_CUSTOM_SERVERS, mapper.writeValueAsString(settings.customServers.filter { it.isNotBlank() }), ) + prefs.put(KEY_BACKEND, settings.backend.name) + prefs.put(KEY_CORE_RPC, mapper.writeValueAsString(settings.namecoinCoreRpc)) + prefs.putBoolean(KEY_FALLBACK_CUSTOM_EX, settings.fallbackToCustomElectrumx) + prefs.putBoolean(KEY_FALLBACK_DEFAULT_EX, settings.fallbackToDefaultElectrumx) prefs.flush() } catch (e: Exception) { System.err.println("NamecoinPrefs: Error writing preferences: ${e.message}") @@ -163,7 +255,32 @@ class DesktopNamecoinPreferences( } else { emptyList() } - NamecoinSettings(enabled = enabled, customServers = servers) + val backend = + prefs.get(KEY_BACKEND, null)?.let { + try { + NamecoinBackend.valueOf(it) + } catch (_: Exception) { + NamecoinBackend.ELECTRUMX + } + } ?: NamecoinBackend.ELECTRUMX + val coreRpc = + prefs.get(KEY_CORE_RPC, null)?.let { raw -> + try { + mapper.readValue(raw) + } catch (_: Exception) { + null + } + } ?: NamecoinCoreRpcConfig() + val fallbackCustom = prefs.getBoolean(KEY_FALLBACK_CUSTOM_EX, false) + val fallbackDefault = prefs.getBoolean(KEY_FALLBACK_DEFAULT_EX, false) + NamecoinSettings( + enabled = enabled, + customServers = servers, + backend = backend, + namecoinCoreRpc = coreRpc, + fallbackToCustomElectrumx = fallbackCustom, + fallbackToDefaultElectrumx = fallbackDefault, + ) } catch (e: Exception) { System.err.println("NamecoinPrefs: Error reading preferences: ${e.message}") NamecoinSettings.DEFAULT diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NamecoinSettingsSection.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NamecoinSettingsSection.kt index fbe1aeabd2..ab82d59f3a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NamecoinSettingsSection.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/settings/NamecoinSettingsSection.kt @@ -36,11 +36,14 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -69,8 +72,12 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinSettings import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.DEFAULT_ELECTRUMX_SERVERS import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinBackend +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcConfig +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.RpcProbeResult import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ServerTestResult import kotlinx.coroutines.launch +import java.util.Locale /** * Complete settings section for Namecoin ElectrumX server configuration. @@ -91,6 +98,19 @@ import kotlinx.coroutines.launch * @param onPinCert Called with a PEM-encoded cert when the user * accepts a TOFU pin prompt. Mirrors the Android * callback contract. + * @param onSetBackend Switch the primary resolution backend + * (ElectrumX vs Namecoin Core RPC). + * @param onSetCoreRpcConfig Persist Namecoin Core JSON-RPC + * connection details. + * @param onSetFallbackToCustomElectrumx Toggle Core RPC → custom ElectrumX + * fallback. Ignored when ElectrumX + * is the primary backend. + * @param onSetFallbackToDefaultElectrumx Toggle fallback to hardcoded public + * ElectrumX defaults. + * @param onTestCoreRpc Probe an ad-hoc [NamecoinCoreRpcConfig] + * without persisting it. `null` hides + * the Test RPC button (e.g. when no + * HTTP client is plumbed in yet). */ @Composable fun NamecoinSettingsSection( @@ -102,6 +122,11 @@ fun NamecoinSettingsSection( modifier: Modifier = Modifier, onTestServer: (suspend (ElectrumxServer) -> ServerTestResult)? = null, onPinCert: ((String) -> Unit)? = null, + onSetBackend: (NamecoinBackend) -> Unit = {}, + onSetCoreRpcConfig: (NamecoinCoreRpcConfig) -> Unit = {}, + onSetFallbackToCustomElectrumx: (Boolean) -> Unit = {}, + onSetFallbackToDefaultElectrumx: (Boolean) -> Unit = {}, + onTestCoreRpc: (suspend (NamecoinCoreRpcConfig) -> RpcProbeResult)? = null, ) { Column(modifier = modifier.padding(16.dp)) { // ── Section header ───────────────────────────────────────── @@ -117,16 +142,52 @@ fun NamecoinSettingsSection( // ── Explanation ───────────────────────────────────── Text( - "Namecoin names (.bit, d/, id/) are resolved via ElectrumX servers. " + - "By default, public community servers are used. " + - "For maximum privacy, add your own server below — when custom " + - "servers are set, the defaults are completely ignored.", + "Namecoin names (.bit, d/, id/) are resolved by querying the Namecoin " + + "blockchain. Choose ElectrumX (light-client) or Namecoin Core RPC " + + "(your own full node) below.", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Spacer(Modifier.height(16.dp)) + // ── Backend selector ─────────────────────────────── + NamecoinBackendSelector( + selected = settings.backend, + onSelect = onSetBackend, + ) + + Spacer(Modifier.height(12.dp)) + + AnimatedVisibility( + visible = settings.backend == NamecoinBackend.NAMECOIN_CORE_RPC, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column { + NamecoinCoreRpcSection( + config = settings.namecoinCoreRpc, + onConfigChange = onSetCoreRpcConfig, + onTestCoreRpc = onTestCoreRpc, + onPinCert = onPinCert ?: {}, + ) + Spacer(Modifier.height(16.dp)) + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f), + ) + Spacer(Modifier.height(16.dp)) + } + } + + // ── Fallback policy ──────────────────────────────── + NamecoinFallbacksSection( + settings = settings, + onSetFallbackToCustomElectrumx = onSetFallbackToCustomElectrumx, + onSetFallbackToDefaultElectrumx = onSetFallbackToDefaultElectrumx, + ) + + Spacer(Modifier.height(16.dp)) + // ── Active servers display ───────────────────────── NamecoinActiveServersDisplay(settings = settings) @@ -619,3 +680,517 @@ private fun NamecoinServerRow( ) } } + +// ── Backend selector ────────────────────────────────────────────────── + +@Composable +private fun NamecoinBackendSelector( + selected: NamecoinBackend, + onSelect: (NamecoinBackend) -> Unit, +) { + Column { + Text( + "Resolution backend", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(6.dp)) + + NamecoinBackendRadioRow( + label = "ElectrumX (light client)", + description = "Query public or custom ElectrumX servers. Lightweight, no full node required.", + selected = selected == NamecoinBackend.ELECTRUMX, + onClick = { onSelect(NamecoinBackend.ELECTRUMX) }, + ) + NamecoinBackendRadioRow( + label = "Namecoin Core RPC", + description = "Query your own Namecoin Core node directly via JSON-RPC. Most sovereign.", + selected = selected == NamecoinBackend.NAMECOIN_CORE_RPC, + onClick = { onSelect(NamecoinBackend.NAMECOIN_CORE_RPC) }, + ) + } +} + +@Composable +private fun NamecoinBackendRadioRow( + label: String, + description: String, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.Top, + ) { + RadioButton( + selected = selected, + onClick = onClick, + ) + Spacer(Modifier.width(4.dp)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +// ── Namecoin Core RPC sub-section ────────────────────────────────────── + +/** + * Holds a Core RPC server cert pending user confirmation before pinning + * (TOFU). Kept separate from the ElectrumX [PendingCertPin] above because + * the two prompts can race when both backends are configured. + */ +private data class RpcPendingCertPin( + val serverHost: String, + val fingerprint: String, + val pem: String, +) + +@Composable +private fun NamecoinCoreRpcSection( + config: NamecoinCoreRpcConfig, + onConfigChange: (NamecoinCoreRpcConfig) -> Unit, + onTestCoreRpc: (suspend (NamecoinCoreRpcConfig) -> RpcProbeResult)?, + onPinCert: (String) -> Unit, +) { + val scope = rememberCoroutineScope() + var url by rememberSaveable(config.url) { mutableStateOf(config.url) } + var user by rememberSaveable(config.username) { mutableStateOf(config.username) } + var pass by rememberSaveable(config.password) { mutableStateOf(config.password) } + var passVisible by remember { mutableStateOf(false) } + var testing by remember { mutableStateOf(false) } + var lastProbe by remember { mutableStateOf(null) } + var pendingPin by remember { mutableStateOf(null) } + + // ── Cert confirmation dialog (TOFU) ────────────────────────────── + pendingPin?.let { pin -> + AlertDialog( + onDismissRequest = { pendingPin = null }, + title = { Text("Pin Namecoin Core RPC certificate?") }, + text = { + Column { + Text( + "Trust this certificate for ${pin.serverHost}? " + + "Subsequent Namecoin Core RPC calls against this host will " + + "require the same cert.", + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(12.dp)) + Text( + "SHA-256:", + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = pin.fingerprint, + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + confirmButton = { + Button(onClick = { + onPinCert(pin.pem) + onConfigChange( + config.copy( + url = url.trim(), + username = user.trim(), + password = pass, + usePinnedTrustStore = true, + ), + ) + pendingPin = null + }) { + Text("Trust") + } + }, + dismissButton = { + TextButton(onClick = { pendingPin = null }) { + Text("Skip") + } + }, + ) + } + + fun commit() { + onConfigChange( + config.copy( + url = url.trim(), + username = user.trim(), + password = pass, + ), + ) + } + + Column { + Text( + "Namecoin Core RPC endpoint", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(4.dp)) + Text( + "URL, username and password for your Namecoin Core node. " + + "StartOS / Start9 users: copy these from the package's Properties tab. " + + "Umbrel users: see the Namecoin Core app's 'Connect From Outside' card. " + + "Tor onion URL recommended for remote access.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = url, + onValueChange = { url = it }, + label = { Text("RPC URL") }, + placeholder = { Text("http://.onion:8336/") }, + singleLine = true, + modifier = + Modifier + .fillMaxWidth() + .onPreviewKeyEvent { ev -> + if (ev.type == KeyEventType.KeyDown && ev.key == Key.Enter) { + commit() + true + } else { + false + } + }, + shape = RoundedCornerShape(8.dp), + textStyle = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + ) + + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = user, + onValueChange = { user = it }, + label = { Text("RPC username") }, + singleLine = true, + modifier = + Modifier + .fillMaxWidth() + .onPreviewKeyEvent { ev -> + if (ev.type == KeyEventType.KeyDown && ev.key == Key.Enter) { + commit() + true + } else { + false + } + }, + shape = RoundedCornerShape(8.dp), + textStyle = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + ) + + Spacer(Modifier.height(8.dp)) + + OutlinedTextField( + value = pass, + onValueChange = { pass = it }, + label = { Text("RPC password") }, + singleLine = true, + visualTransformation = + if (passVisible) { + androidx.compose.ui.text.input.VisualTransformation.None + } else { + androidx.compose.ui.text.input + .PasswordVisualTransformation() + }, + trailingIcon = { + IconButton(onClick = { passVisible = !passVisible }) { + Icon( + MaterialSymbols.Lock, + contentDescription = null, + ) + } + }, + modifier = + Modifier + .fillMaxWidth() + .onPreviewKeyEvent { ev -> + if (ev.type == KeyEventType.KeyDown && ev.key == Key.Enter) { + commit() + true + } else { + false + } + }, + shape = RoundedCornerShape(8.dp), + textStyle = + MaterialTheme.typography.bodySmall.copy( + fontFamily = FontFamily.Monospace, + ), + ) + + Spacer(Modifier.height(10.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { commit() }, + modifier = Modifier.weight(1f), + ) { + Text("Save") + } + if (onTestCoreRpc != null) { + Button( + onClick = { + commit() + if (testing) return@Button + testing = true + lastProbe = null + val candidate = + config.copy( + url = url.trim(), + username = user.trim(), + password = pass, + ) + scope.launch { + try { + val probe = onTestCoreRpc(candidate) + lastProbe = probe + val pem = probe.serverCertPem + val fp = probe.certFingerprint + if ( + pem != null && fp != null && ( + probe.tlsHandshakeFailed || + !candidate.usePinnedTrustStore + ) + ) { + val host = + try { + java.net.URI(candidate.url).host + ?: candidate.url + } catch (_: Exception) { + candidate.url + } + pendingPin = + RpcPendingCertPin( + serverHost = host, + fingerprint = fp, + pem = pem, + ) + } + } finally { + testing = false + } + } + }, + modifier = Modifier.weight(1f), + enabled = !testing && url.isNotBlank(), + ) { + if (testing) { + CircularProgressIndicator( + modifier = Modifier.size(14.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.onPrimary, + ) + } else { + Text("Test RPC") + } + } + } + } + + lastProbe?.let { probe -> + Spacer(Modifier.height(10.dp)) + Card( + colors = + CardDefaults.cardColors( + containerColor = + if (probe.success) { + Color(0x222E8B57) + } else { + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.6f) + }, + ), + shape = RoundedCornerShape(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(10.dp)) { + if (probe.success) { + Text( + "Connected (${probe.elapsedMs} ms)", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + val chain = probe.chain ?: "?" + val blocks = probe.blocks?.toString() ?: "?" + val pct = + probe.verificationProgress?.let { + String.format(Locale.ROOT, "%.2f%%", it * 100) + } ?: "?" + Text( + "chain=$chain height=$blocks sync=$pct" + + (if (probe.initialBlockDownload == true) " (IBD)" else ""), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + } else { + Text( + "Failed", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + ) + Text( + probe.error.orEmpty(), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + ) + if (probe.tlsHandshakeFailed && probe.certFingerprint != null) { + Spacer(Modifier.height(6.dp)) + Text( + "TLS rejected by the system trust store. Tap Test RPC " + + "again, then choose Trust to pin this server's certificate.", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + val fp = probe.certFingerprint + if (fp != null) { + Spacer(Modifier.height(6.dp)) + Text( + text = + "cert SHA-256: ${fp.take(23)}\u2026" + + if (config.usePinnedTrustStore) " (pinned)" else "", + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + } +} + +// ── Fallback policy section ──────────────────────────────────────────── + +@Composable +private fun NamecoinFallbacksSection( + settings: NamecoinSettings, + onSetFallbackToCustomElectrumx: (Boolean) -> Unit, + onSetFallbackToDefaultElectrumx: (Boolean) -> Unit, +) { + Column { + Text( + "Fallback policy", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.SemiBold, + ) + Spacer(Modifier.height(4.dp)) + Text( + "What to try if the primary backend can't be reached. " + + "Off by default — fallbacks can leak your lookups to other operators.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(8.dp)) + + if (settings.backend == NamecoinBackend.NAMECOIN_CORE_RPC) { + NamecoinToggleRow( + label = "Fall back to my custom ElectrumX servers", + description = + if (settings.hasCustomServers) { + "If Namecoin Core RPC fails, try the ${settings.customServers.size} " + + "custom ElectrumX server(s) configured below." + } else { + "No custom ElectrumX servers configured. Add one in the section below " + + "to enable this option." + }, + checked = settings.fallbackToCustomElectrumx, + onCheckedChange = onSetFallbackToCustomElectrumx, + enabled = settings.hasCustomServers, + ) + } + + NamecoinToggleRow( + label = "Fall back to default public ElectrumX servers", + description = + if (settings.backend == NamecoinBackend.ELECTRUMX) { + if (settings.hasCustomServers) { + "If all of my custom ElectrumX servers fail, also try the hardcoded " + + "public defaults." + } else { + "(Already using defaults — toggle has no extra effect.)" + } + } else { + "If everything above fails, try the hardcoded public ElectrumX servers." + }, + checked = settings.fallbackToDefaultElectrumx, + onCheckedChange = onSetFallbackToDefaultElectrumx, + enabled = + settings.backend == NamecoinBackend.NAMECOIN_CORE_RPC || + settings.hasCustomServers, + ) + } +} + +@Composable +private fun NamecoinToggleRow( + label: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + enabled: Boolean = true, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = + if (enabled) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + Text( + text = description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + enabled = enabled, + ) + } +} diff --git a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferencesTest.kt b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferencesTest.kt index 94a57dee30..34d8cc3dd2 100644 --- a/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferencesTest.kt +++ b/desktopApp/src/jvmTest/kotlin/com/vitorpamplona/amethyst/desktop/service/namecoin/DesktopNamecoinPreferencesTest.kt @@ -20,6 +20,9 @@ */ package com.vitorpamplona.amethyst.desktop.service.namecoin +import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin.NamecoinSettings +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinBackend +import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcConfig import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Assert.assertEquals @@ -226,4 +229,120 @@ class DesktopNamecoinPreferencesTest { assertEquals(listOf(samplePem1), namecoinPrefs.loadPinnedCerts()) } + + // ── Backend / Core RPC / fallback round-trip ────────────────────── + + @Test + fun `default backend is ElectrumX with empty Core RPC config and no fallbacks`() { + val settings = namecoinPrefs.current + assertEquals(NamecoinBackend.ELECTRUMX, settings.backend) + assertEquals(NamecoinCoreRpcConfig(), settings.namecoinCoreRpc) + assertFalse(settings.fallbackToCustomElectrumx) + assertFalse(settings.fallbackToDefaultElectrumx) + } + + @Test + fun `setBackend persists and updates flow`() = + runBlocking { + namecoinPrefs.setBackend(NamecoinBackend.NAMECOIN_CORE_RPC) + assertEquals(NamecoinBackend.NAMECOIN_CORE_RPC, namecoinPrefs.current.backend) + + val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) + assertEquals(NamecoinBackend.NAMECOIN_CORE_RPC, reloaded.current.backend) + } + + @Test + fun `setCoreRpcConfig persists URL username password`() = + runBlocking { + val cfg = + NamecoinCoreRpcConfig( + url = "http://abc.onion:8336/", + username = "rpcuser", + password = "hunter2", + ) + namecoinPrefs.setCoreRpcConfig(cfg) + assertEquals(cfg, namecoinPrefs.current.namecoinCoreRpc) + + val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) + assertEquals(cfg, reloaded.current.namecoinCoreRpc) + } + + @Test + fun `setCoreRpcConfig round-trips usePinnedTrustStore flag`() = + runBlocking { + val cfg = + NamecoinCoreRpcConfig( + url = "https://lan-host:8336/", + username = "u", + password = "p", + usePinnedTrustStore = true, + ) + namecoinPrefs.setCoreRpcConfig(cfg) + val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) + assertTrue(reloaded.current.namecoinCoreRpc.usePinnedTrustStore) + } + + @Test + fun `setFallbackToCustomElectrumx persists`() = + runBlocking { + namecoinPrefs.setFallbackToCustomElectrumx(true) + assertTrue(namecoinPrefs.current.fallbackToCustomElectrumx) + + val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) + assertTrue(reloaded.current.fallbackToCustomElectrumx) + } + + @Test + fun `setFallbackToDefaultElectrumx persists`() = + runBlocking { + namecoinPrefs.setFallbackToDefaultElectrumx(true) + assertTrue(namecoinPrefs.current.fallbackToDefaultElectrumx) + + val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) + assertTrue(reloaded.current.fallbackToDefaultElectrumx) + } + + @Test + fun `reset clears backend Core RPC and fallback flags`() = + runBlocking { + namecoinPrefs.setBackend(NamecoinBackend.NAMECOIN_CORE_RPC) + namecoinPrefs.setCoreRpcConfig( + NamecoinCoreRpcConfig(url = "http://x:8336/", username = "u", password = "p"), + ) + namecoinPrefs.setFallbackToCustomElectrumx(true) + namecoinPrefs.setFallbackToDefaultElectrumx(true) + + namecoinPrefs.reset() + + val s = namecoinPrefs.current + assertEquals(NamecoinBackend.ELECTRUMX, s.backend) + assertEquals(NamecoinCoreRpcConfig(), s.namecoinCoreRpc) + assertFalse(s.fallbackToCustomElectrumx) + assertFalse(s.fallbackToDefaultElectrumx) + + val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) + assertEquals(NamecoinSettings.DEFAULT, reloaded.current) + } + + @Test + fun `full round-trip across all backend fields`() = + runBlocking { + namecoinPrefs.setEnabled(true) + namecoinPrefs.addServer("server1.com:50006") + namecoinPrefs.setBackend(NamecoinBackend.NAMECOIN_CORE_RPC) + val cfg = + NamecoinCoreRpcConfig( + url = "http://onion.onion:8336/", + username = "alice", + password = "s3cret", + usePinnedTrustStore = false, + ) + namecoinPrefs.setCoreRpcConfig(cfg) + namecoinPrefs.setFallbackToCustomElectrumx(true) + namecoinPrefs.setFallbackToDefaultElectrumx(true) + + val snapshot = namecoinPrefs.current + val reloaded = DesktopNamecoinPreferences(prefs = testPrefs) + assertEquals(snapshot, reloaded.current) + } }