refactor(namecoin): consolidate NamecoinSettings into commons

Two NamecoinSettings classes had drifted:

- commons (used by Desktop): only enabled + customServers
- amethyst service.namecoin (used by Android): full schema with backend,
  namecoinCoreRpc, fallbackToCustomElectrumx, fallbackToDefaultElectrumx

This left Desktop unable to persist any of the Namecoin Core RPC or
fallback-policy state introduced in the Android settings UI. Promote
the rich Android version into commons as the single source of truth
and delete the Android duplicate.

- Move the rich schema (backend, namecoinCoreRpc, fallback toggles,
  hasUsableCoreRpc, toFallbackPolicy) into the commons NamecoinSettings.
- Delete amethyst/service/namecoin/NamecoinSettings.kt and its test.
- Repoint the two Android imports (NamecoinSharedPreferences,
  NamecoinSettingsSection) at the commons class. No behaviour change on
  Android.
- Fold the Android-only backend/RPC/fallback test cases into the commons
  NamecoinSettingsTest so the shared schema stays covered.

Desktop persistence (DesktopNamecoinPreferences) still only reads/writes
enabled + customServers; the extra commons fields fall back to defaults
on the existing Desktop store. Wiring those new fields into Desktop is
the next change.
This commit is contained in:
m
2026-05-28 05:06:44 +10:00
parent d5cca1e3e4
commit 67edb32fa7
6 changed files with 126 additions and 258 deletions
@@ -25,7 +25,7 @@ import androidx.compose.runtime.Stable
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import com.vitorpamplona.amethyst.service.namecoin.NamecoinSettings
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
@@ -1,130 +0,0 @@
/*
* 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.amethyst.service.namecoin
import androidx.compose.runtime.Stable
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.NamecoinFallbackPolicy
import kotlinx.serialization.Serializable
/**
* Immutable data class representing the current Namecoin resolution config.
*
* Two backends are available:
*
* - **ElectrumX** (default): zero-config — public servers handle lookups
* unless the user adds custom entries via [customServers]. When custom
* servers are configured, they are used EXCLUSIVELY (defaults ignored)
* unless [fallbackToDefaultElectrumx] is enabled.
*
* - **Namecoin Core RPC**: queries a user-supplied Namecoin Core full
* node directly. Most sovereign option. [namecoinCoreRpc] holds the
* URL / credentials. Fallback to ElectrumX is opt-in via
* [fallbackToCustomElectrumx] / [fallbackToDefaultElectrumx].
*/
@Serializable
@Stable
data class NamecoinSettings(
/** Whether Namecoin resolution is enabled at all. */
val enabled: Boolean = true,
/**
* Custom ElectrumX servers. Each entry is `host:port` (TLS) or
* `host:port:tcp` (plaintext). When non-empty, these replace the
* defaults *for the ElectrumX backend*.
*/
val customServers: List<String> = emptyList(),
/** Which backend is the *primary* lookup path. */
val backend: NamecoinBackend = NamecoinBackend.ELECTRUMX,
/** Namecoin Core RPC connection details (only meaningful when backend == NAMECOIN_CORE_RPC). */
val namecoinCoreRpc: NamecoinCoreRpcConfig = NamecoinCoreRpcConfig(),
/**
* If the primary backend fails (Core RPC unreachable, or custom
* ElectrumX servers all dead), fall back to the user's custom
* ElectrumX servers.
*
* Only meaningful when backend == NAMECOIN_CORE_RPC. Ignored when the
* ElectrumX backend is primary (because custom servers ARE the
* primary in that case).
*/
val fallbackToCustomElectrumx: Boolean = false,
/**
* If everything above fails, try the hardcoded public ElectrumX
* defaults. Applies to both backends: when backend == ELECTRUMX with
* custom servers configured, enabling this widens the search to the
* defaults too instead of stopping after the custom list.
*/
val fallbackToDefaultElectrumx: Boolean = false,
) {
/** True when the user has configured at least one custom ElectrumX server. */
val hasCustomServers: Boolean get() = customServers.isNotEmpty()
/** True when Namecoin Core RPC settings are filled in enough to use. */
val hasUsableCoreRpc: Boolean get() = namecoinCoreRpc.isUsable
/**
* Convert custom ElectrumX entries into [ElectrumxServer] instances.
* Returns `null` when none are valid (resolver should fall back to
* defaults, subject to the configured policy).
*/
fun toElectrumxServers(): List<ElectrumxServer>? {
if (customServers.isEmpty()) return null
return customServers
.mapNotNull { parseServerString(it) }
.ifEmpty { null }
}
/** Translate the fallback toggles to the quartz [NamecoinFallbackPolicy]. */
fun toFallbackPolicy(): NamecoinFallbackPolicy =
NamecoinFallbackPolicy(
fallbackToCustomElectrumx = fallbackToCustomElectrumx,
fallbackToDefaultElectrumx = fallbackToDefaultElectrumx,
)
companion object {
val DEFAULT = NamecoinSettings()
/**
* Parse `host:port` or `host:port:tcp` into an [ElectrumxServer].
*/
fun parseServerString(s: String): ElectrumxServer? {
val parts = s.trim().split(":")
if (parts.size < 2) return null
val host = parts[0].trim()
val port = parts[1].trim().toIntOrNull() ?: return null
if (host.isEmpty() || port <= 0 || port > 65535) return null
val useSsl = parts.getOrNull(2)?.trim()?.lowercase() != "tcp"
return ElectrumxServer(
host = host,
port = port,
useSsl = useSsl,
usePinnedTrustStore = true,
)
}
/** Format an [ElectrumxServer] back to the `host:port[:tcp]` string form. */
fun formatServerString(server: ElectrumxServer): String {
val base = "${server.host}:${server.port}"
return if (server.useSsl) base else "$base:tcp"
}
}
}
@@ -71,7 +71,7 @@ import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.service.namecoin.NamecoinSettings
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
@@ -1,110 +0,0 @@
/*
* 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.amethyst.service.namecoin
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinBackend
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcConfig
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class NamecoinSettingsTest {
// ── Server-string parser ───────────────────────────────────────────
@Test
fun `parses host port as TLS by default`() {
val s = NamecoinSettings.parseServerString("electrumx.example.com:50002")
assertNotNull(s)
assertEquals("electrumx.example.com", s!!.host)
assertEquals(50002, s.port)
assertTrue(s.useSsl)
assertTrue(s.usePinnedTrustStore)
}
@Test
fun `parses host port tcp as plaintext`() {
val s = NamecoinSettings.parseServerString("electrumx.local:50001:tcp")
assertNotNull(s)
assertFalse(s!!.useSsl)
}
@Test
fun `rejects malformed strings`() {
assertNull(NamecoinSettings.parseServerString("only-host"))
assertNull(NamecoinSettings.parseServerString("host:notaport"))
assertNull(NamecoinSettings.parseServerString(":1234"))
assertNull(NamecoinSettings.parseServerString("host:0"))
assertNull(NamecoinSettings.parseServerString("host:65536"))
}
// ── Backend / RPC plumbing ─────────────────────────────────────────
@Test
fun `default backend is electrumx`() {
assertEquals(NamecoinBackend.ELECTRUMX, NamecoinSettings.DEFAULT.backend)
}
@Test
fun `default fallback policy is all-off`() {
val s = NamecoinSettings.DEFAULT
assertFalse(s.fallbackToCustomElectrumx)
assertFalse(s.fallbackToDefaultElectrumx)
val policy = s.toFallbackPolicy()
assertFalse(policy.fallbackToCustomElectrumx)
assertFalse(policy.fallbackToDefaultElectrumx)
}
@Test
fun `hasUsableCoreRpc requires http url`() {
assertFalse(NamecoinSettings.DEFAULT.hasUsableCoreRpc)
val ok =
NamecoinSettings.DEFAULT.copy(
namecoinCoreRpc =
NamecoinCoreRpcConfig(
url = "http://node.local:8336/",
username = "u",
password = "p",
),
)
assertTrue(ok.hasUsableCoreRpc)
val bad =
NamecoinSettings.DEFAULT.copy(
namecoinCoreRpc = NamecoinCoreRpcConfig(url = "node.local:8336"),
)
assertFalse(bad.hasUsableCoreRpc)
}
@Test
fun `toFallbackPolicy mirrors toggles`() {
val s =
NamecoinSettings.DEFAULT.copy(
fallbackToCustomElectrumx = true,
fallbackToDefaultElectrumx = true,
)
val p = s.toFallbackPolicy()
assertTrue(p.fallbackToCustomElectrumx)
assertTrue(p.fallbackToDefaultElectrumx)
}
}
@@ -22,14 +22,31 @@ package com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin
import androidx.compose.runtime.Stable
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.NamecoinFallbackPolicy
import kotlinx.serialization.Serializable
/**
* Immutable data class representing the current Namecoin resolution config.
*
* When custom servers are configured, they are used EXCLUSIVELY and the
* hardcoded defaults are ignored. This gives privacy-conscious users full
* control over which ElectrumX servers observe their name lookups.
* Two backends are available:
*
* - **ElectrumX** (default): zero-config — public servers handle lookups
* unless the user adds custom entries via [customServers]. When custom
* servers are configured, they are used EXCLUSIVELY (defaults ignored)
* unless [fallbackToDefaultElectrumx] is enabled.
*
* - **Namecoin Core RPC**: queries a user-supplied Namecoin Core full
* node directly. Most sovereign option. [namecoinCoreRpc] holds the
* URL / credentials. Fallback to ElectrumX is opt-in via
* [fallbackToCustomElectrumx] / [fallbackToDefaultElectrumx].
*
* Lives in `commons` so both Android and Desktop persistence layers can
* share the same schema. Each platform brings its own storage adapter
* (DataStore on Android, [java.util.prefs.Preferences] on Desktop) and
* its own pinned-cert store, but the round-tripped data class is the
* same on both.
*/
@Serializable
@Stable
@@ -37,18 +54,43 @@ data class NamecoinSettings(
/** Whether Namecoin resolution is enabled at all. */
val enabled: Boolean = true,
/**
* Custom ElectrumX servers. When non-empty, these replace the defaults.
*
* Each entry is `host:port` (TLS) or `host:port:tcp` (plaintext).
* Custom ElectrumX servers. Each entry is `host:port` (TLS) or
* `host:port:tcp` (plaintext). When non-empty, these replace the
* defaults *for the ElectrumX backend*.
*/
val customServers: List<String> = emptyList(),
/** Which backend is the *primary* lookup path. */
val backend: NamecoinBackend = NamecoinBackend.ELECTRUMX,
/** Namecoin Core RPC connection details (only meaningful when backend == NAMECOIN_CORE_RPC). */
val namecoinCoreRpc: NamecoinCoreRpcConfig = NamecoinCoreRpcConfig(),
/**
* If the primary backend fails (Core RPC unreachable, or custom
* ElectrumX servers all dead), fall back to the user's custom
* ElectrumX servers.
*
* Only meaningful when backend == NAMECOIN_CORE_RPC. Ignored when the
* ElectrumX backend is primary (because custom servers ARE the
* primary in that case).
*/
val fallbackToCustomElectrumx: Boolean = false,
/**
* If everything above fails, try the hardcoded public ElectrumX
* defaults. Applies to both backends: when backend == ELECTRUMX with
* custom servers configured, enabling this widens the search to the
* defaults too instead of stopping after the custom list.
*/
val fallbackToDefaultElectrumx: Boolean = false,
) {
/** True when the user has configured at least one custom server. */
/** True when the user has configured at least one custom ElectrumX server. */
val hasCustomServers: Boolean get() = customServers.isNotEmpty()
/** True when Namecoin Core RPC settings are filled in enough to use. */
val hasUsableCoreRpc: Boolean get() = namecoinCoreRpc.isUsable
/**
* Convert to [ElectrumxServer] instances used by the resolver.
* Returns `null` when no valid custom servers are configured (use defaults).
* Convert custom ElectrumX entries into [ElectrumxServer] instances.
* Returns `null` when none are valid (resolver should fall back to
* defaults, subject to the configured policy).
*/
fun toElectrumxServers(): List<ElectrumxServer>? {
if (customServers.isEmpty()) return null
@@ -57,17 +99,25 @@ data class NamecoinSettings(
.ifEmpty { null }
}
/** Translate the fallback toggles to the quartz [NamecoinFallbackPolicy]. */
fun toFallbackPolicy(): NamecoinFallbackPolicy =
NamecoinFallbackPolicy(
fallbackToCustomElectrumx = fallbackToCustomElectrumx,
fallbackToDefaultElectrumx = fallbackToDefaultElectrumx,
)
companion object {
val DEFAULT = NamecoinSettings()
/**
* Parse `host:port` or `host:port:tcp` into an [ElectrumxServer].
*
* TLS is the default protocol. Append `:tcp` for plaintext
* TLS is the default protocol. Append `:tcp` for plaintext
* (useful for `.onion` addresses and local servers).
*
* `.onion` addresses automatically get `usePinnedTrustStore = true`
* since certificate verification is meaningless over Tor.
* All custom servers get `usePinnedTrustStore = true` — the
* ElectrumXClient layer then decides whether to require a pinned
* cert (TLS) or skip cert checks entirely (onion / plaintext).
*/
fun parseServerString(s: String): ElectrumxServer? {
val parts = s.trim().split(":")
@@ -76,7 +126,6 @@ data class NamecoinSettings(
val port = parts[1].trim().toIntOrNull() ?: return null
if (host.isEmpty() || port <= 0 || port > 65535) return null
val useSsl = parts.getOrNull(2)?.trim()?.lowercase() != "tcp"
val isOnion = host.endsWith(".onion")
return ElectrumxServer(
host = host,
port = port,
@@ -85,9 +134,7 @@ data class NamecoinSettings(
)
}
/**
* Format an [ElectrumxServer] back to the `host:port[:tcp]` string form.
*/
/** Format an [ElectrumxServer] back to the `host:port[:tcp]` string form. */
fun formatServerString(server: ElectrumxServer): String {
val base = "${server.host}:${server.port}"
return if (server.useSsl) base else "$base:tcp"
@@ -21,6 +21,8 @@
package com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.namecoin
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinBackend
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinCoreRpcConfig
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
@@ -178,4 +180,63 @@ class NamecoinSettingsTest {
assertTrue(d.customServers.isEmpty())
assertFalse(d.hasCustomServers)
}
// ── Backend / RPC plumbing ─────────────────────────────────────────
@Test
fun `default backend is electrumx`() {
assertEquals(NamecoinBackend.ELECTRUMX, NamecoinSettings.DEFAULT.backend)
}
@Test
fun `default fallback policy is all-off`() {
val s = NamecoinSettings.DEFAULT
assertFalse(s.fallbackToCustomElectrumx)
assertFalse(s.fallbackToDefaultElectrumx)
val policy = s.toFallbackPolicy()
assertFalse(policy.fallbackToCustomElectrumx)
assertFalse(policy.fallbackToDefaultElectrumx)
}
@Test
fun `hasUsableCoreRpc requires http url`() {
assertFalse(NamecoinSettings.DEFAULT.hasUsableCoreRpc)
val ok =
NamecoinSettings.DEFAULT.copy(
namecoinCoreRpc =
NamecoinCoreRpcConfig(
url = "http://node.local:8336/",
username = "u",
password = "p",
),
)
assertTrue(ok.hasUsableCoreRpc)
val bad =
NamecoinSettings.DEFAULT.copy(
namecoinCoreRpc = NamecoinCoreRpcConfig(url = "node.local:8336"),
)
assertFalse(bad.hasUsableCoreRpc)
}
@Test
fun `toFallbackPolicy mirrors toggles`() {
val s =
NamecoinSettings.DEFAULT.copy(
fallbackToCustomElectrumx = true,
fallbackToDefaultElectrumx = true,
)
val p = s.toFallbackPolicy()
assertTrue(p.fallbackToCustomElectrumx)
assertTrue(p.fallbackToDefaultElectrumx)
}
@Test
fun `backend can be set to namecoin core rpc`() {
val s =
NamecoinSettings.DEFAULT.copy(
backend = NamecoinBackend.NAMECOIN_CORE_RPC,
)
assertEquals(NamecoinBackend.NAMECOIN_CORE_RPC, s.backend)
}
}