diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt index 8b3f757f73..3f37ce3066 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/NamecoinSharedPreferences.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt deleted file mode 100644 index 429ed12b38..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettings.kt +++ /dev/null @@ -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 = 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? { - 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" - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt index 2a1acbdb4b..c246e9ac92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/NamecoinSettingsSection.kt @@ -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 diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt deleted file mode 100644 index 6e2f2df948..0000000000 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinSettingsTest.kt +++ /dev/null @@ -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) - } -} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinSettings.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinSettings.kt index 942653aa33..60537047a9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinSettings.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinSettings.kt @@ -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 = 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? { 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" diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinSettingsTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinSettingsTest.kt index 5bd20151df..3bcd5f52e7 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinSettingsTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/model/nip05DnsIdentifiers/namecoin/NamecoinSettingsTest.kt @@ -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) + } }