From d82ace2f63670c3e1025a43589aec917e85a3792 Mon Sep 17 00:00:00 2001 From: M Date: Mon, 2 Mar 2026 13:07:46 +1100 Subject: [PATCH 1/6] feat: Namecoin NIP-05 identity verification via ElectrumX Add censorship-resistant NIP-05 verification using the Namecoin blockchain. Users can set their nip05 field to a .bit domain (e.g. alice@example.bit) or direct Namecoin name (d/example, id/alice) and Amethyst will resolve the pubkey mapping via ElectrumX instead of HTTP. Resolution uses the standard Electrum protocol (scripthash-based lookups): - Build canonical name index script matching ElectrumX-NMC indexing - Query blockchain.scripthash.get_history for the name's tx history - Parse NAME_UPDATE script from the latest transaction output - Extract Nostr pubkey from the name's JSON value Supports both d/ (domain) and id/ (identity) Namecoin namespaces, simple and extended NIP-05-like value formats with relay hints, LRU caching with 1h TTL, and self-signed TLS certificates. New files: - quartz: ElectrumxClient, NamecoinNameResolver, NamecoinLookupCache - amethyst: NamecoinNameService, Nip05NamecoinAdapter, NamecoinVerificationDisplay - docs: namecoin-nip05-design.md - tests: NamecoinNameResolverTest Modified: - Nip05Client: optional namecoinResolver routes .bit to blockchain - AppModules: wire up resolver See docs/namecoin-nip05-design.md for full architecture and protocol details. --- .../com/vitorpamplona/amethyst/AppModules.kt | 7 +- .../service/namecoin/NamecoinNameService.kt | 158 ++++++ .../service/namecoin/Nip05NamecoinAdapter.kt | 62 +++ .../namecoin/NamecoinVerificationDisplay.kt | 259 ++++++++++ .../loggedIn/relays/RelayInformationScreen.kt | 25 +- docs/namecoin-nip05-design.md | 231 +++++++++ .../quartz/nip05/namecoin/ElectrumxClient.kt | 487 ++++++++++++++++++ .../nip05/namecoin/NamecoinLookupCache.kt | 77 +++ .../nip05/namecoin/NamecoinNameResolver.kt | 313 +++++++++++ .../quartz/nip05DnsIdentifiers/Nip05Client.kt | 18 +- .../namecoin/NamecoinNameResolverTest.kt | 281 ++++++++++ 11 files changed, 1908 insertions(+), 10 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/Nip05NamecoinAdapter.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/namecoin/NamecoinVerificationDisplay.kt create mode 100644 docs/namecoin-nip05-design.md create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinLookupCache.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolver.kt create mode 100644 quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 8c13563f3..25ef825e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -142,7 +142,12 @@ class AppModules( // Custom fetcher that considers tor settings and avoids forwarding. val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05) - val nip05Client = Nip05Client(nip05Fetcher) + val namecoinResolver = + com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver( + com.vitorpamplona.quartz.nip05.namecoin + .ElectrumxClient(), + ) + val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver) // Application-wide block height request cache val otsBlockHeightCache by lazy { OtsBlockHeightCache() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt new file mode 100644 index 000000000..178a755b6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt @@ -0,0 +1,158 @@ +/* + * 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.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 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 + +/** + * Application-level singleton for Namecoin name resolution. + * + * Thread-safe, lifecycle-aware, and designed for integration + * into Amethyst's existing `ServiceManager` infrastructure. + */ +class NamecoinNameService private constructor() { + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val electrumxClient = ElectrumxClient() + private val resolver = NamecoinNameResolver(electrumxClient) + private val cache = NamecoinLookupCache() + + // Custom server list (user-configurable) + private var customServers: List = emptyList() + + companion object { + @Volatile + private var instance: NamecoinNameService? = null + + fun getInstance(): NamecoinNameService = + instance ?: synchronized(this) { + instance ?: NamecoinNameService().also { instance = it } + } + } + + // ── Public API ───────────────────────────────────────────────────── + + /** + * Resolve a Namecoin identifier to a Nostr pubkey. + * + * Returns cached results when available. This is the primary method + * that the search bar and NIP-05 verifier should call. + * + * @param identifier e.g. "alice@example.bit", "id/bob", "example.bit" + * @return [NamecoinNostrResult] or null + */ + suspend fun resolve(identifier: String): NamecoinNostrResult? { + // Check cache first + val cached = cache.get(identifier) + if (cached != null) return cached.result + + // Perform lookup + val result = resolver.resolve(identifier) + cache.put(identifier, result) + return result + } + + /** + * Verify that a Namecoin name maps to the expected pubkey. + * + * This is the Namecoin equivalent of NIP-05 verification: + * given a pubkey from a kind-0 event and a `nip05` value + * ending in `.bit`, check that the Namecoin blockchain + * confirms the mapping. + * + * @param nip05Address The nip05 field value, e.g. "alice@example.bit" + * @param expectedPubkeyHex The pubkey from the kind-0 event + * @return true if the Namecoin name resolves to the expected pubkey + */ + suspend fun verifyNip05( + nip05Address: String, + expectedPubkeyHex: String, + ): Boolean { + if (!NamecoinNameResolver.isNamecoinIdentifier(nip05Address)) return false + val result = resolve(nip05Address) ?: return false + return result.pubkey.equals(expectedPubkeyHex, ignoreCase = true) + } + + /** + * Perform a lookup and emit results via a StateFlow. + * + * Useful for composable UIs that observe resolution state. + */ + fun resolveLive(identifier: String): StateFlow { + val state = MutableStateFlow(NamecoinResolveState.Loading) + scope.launch { + try { + val result = resolve(identifier) + state.value = + if (result != null) { + NamecoinResolveState.Resolved(result) + } else { + NamecoinResolveState.NotFound + } + } catch (e: Exception) { + state.value = NamecoinResolveState.Error(e.message ?: "Unknown error") + } + } + return state + } + + /** + * Configure custom ElectrumX servers. + * + * Users who run their own ElectrumX instance can add it here + * for better privacy and reliability. + */ + fun setCustomServers(servers: List) { + customServers = servers + } + + /** + * Clear the resolution cache. + */ + suspend fun clearCache() = cache.clear() +} + +/** + * Observable state for a Namecoin resolution in progress. + */ +sealed class NamecoinResolveState { + data object Loading : NamecoinResolveState() + + data class Resolved( + val result: NamecoinNostrResult, + ) : NamecoinResolveState() + + data object NotFound : NamecoinResolveState() + + data class Error( + val message: String, + ) : NamecoinResolveState() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/Nip05NamecoinAdapter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/Nip05NamecoinAdapter.kt new file mode 100644 index 000000000..706570c16 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/Nip05NamecoinAdapter.kt @@ -0,0 +1,62 @@ +/* + * 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.nip05.namecoin.NamecoinNameResolver + +/** + * Static adapter for hooking Namecoin into NIP-05 verification. + */ +object Nip05NamecoinAdapter { + /** + * Attempt Namecoin-based NIP-05 verification. + * + * @param nip05Address The `nip05` field from a kind-0 event + * @param expectedPubkeyHex The hex pubkey from the same event + * @return `true` if verified via Namecoin, `false` if Namecoin says + * the mapping is wrong, or `null` if this identifier is not + * a Namecoin identifier (caller should fall through to HTTP NIP-05). + */ + suspend fun tryVerify( + nip05Address: String, + expectedPubkeyHex: String, + ): Boolean? { + if (!NamecoinNameResolver.isNamecoinIdentifier(nip05Address)) { + return null // Not a Namecoin identifier → let caller handle via HTTP + } + return NamecoinNameService.getInstance().verifyNip05(nip05Address, expectedPubkeyHex) + } + + /** + * Attempt to resolve a Namecoin identifier from the search bar. + * + * Called from the search/discovery flow when the user types an + * identifier. Returns the hex pubkey if found, null otherwise. + * + * @param query The user's search input + * @return Pair of (pubkey, relayList) or null + */ + suspend fun tryResolveSearch(query: String): Pair>? { + if (!NamecoinNameResolver.isNamecoinIdentifier(query)) return null + val result = NamecoinNameService.getInstance().resolve(query) ?: return null + return Pair(result.pubkey, result.relays) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/namecoin/NamecoinVerificationDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/namecoin/NamecoinVerificationDisplay.kt new file mode 100644 index 000000000..2942b091f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/namecoin/NamecoinVerificationDisplay.kt @@ -0,0 +1,259 @@ +/* + * 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.ui.note.namecoin + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.service.namecoin.NamecoinNameService +import com.vitorpamplona.amethyst.service.namecoin.NamecoinResolveState + +/** + * Display a Namecoin-verified identity badge. + * + * Shows the Namecoin name with a blockchain icon when verified. + * Renders nothing if verification fails or is still loading. + * + * @param nip05 The nip05 field value ending in .bit or starting with id/ + * @param expectedPubkeyHex The profile's pubkey to verify against + * @param modifier Standard compose modifier + */ +@Composable +fun NamecoinVerificationDisplay( + nip05: String, + expectedPubkeyHex: String, + modifier: Modifier = Modifier, +) { + var isVerified by remember(nip05, expectedPubkeyHex) { mutableStateOf(null) } + + LaunchedEffect(nip05, expectedPubkeyHex) { + isVerified = NamecoinNameService.getInstance().verifyNip05(nip05, expectedPubkeyHex) + } + + if (isVerified == true) { + NamecoinBadge( + displayName = formatDisplayName(nip05), + namespace = inferNamespace(nip05), + modifier = modifier, + ) + } +} + +/** + * The visual badge component. + * + * Shows: [chain icon] [display name] + * With namespace-appropriate styling. + */ +@Composable +private fun NamecoinBadge( + displayName: String, + namespace: String, // "d/" or "id/" + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.padding(top = 1.dp, bottom = 1.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + // Blockchain chain-link icon + // In production, replace with a proper vector drawable. + // For now, use a text glyph as placeholder. + Text( + text = "\u26D3", // ⛓ chain link emoji + fontSize = 12.sp, + color = NamecoinColors.chainIcon, + modifier = Modifier.padding(end = 2.dp), + ) + + // Namespace indicator + Text( + text = displayName, + fontSize = 14.sp, + color = NamecoinColors.verifiedText, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +/** + * Composable for the search result when a Namecoin name resolves. + * + * Shows the resolved Namecoin name, the Nostr pubkey, and relay hints. + * Used in the search results list. + */ +@Composable +fun NamecoinSearchResult( + identifier: String, + onProfileClick: (pubkeyHex: String) -> Unit, + modifier: Modifier = Modifier, +) { + val resolveState by NamecoinNameService + .getInstance() + .resolveLive(identifier) + .collectAsState() + + when (val state = resolveState) { + is NamecoinResolveState.Loading -> { + Row( + modifier = modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "\u26D3", // ⛓ + fontSize = 16.sp, + color = NamecoinColors.chainIcon, + ) + Spacer(Modifier.width(8.dp)) + Text( + text = "Resolving $identifier via Namecoin…", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + is NamecoinResolveState.Resolved -> { + Row( + modifier = + modifier + .clickable { onProfileClick(state.result.pubkey) } + .padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "\u26D3", // ⛓ + fontSize = 16.sp, + color = NamecoinColors.verified, + ) + Spacer(Modifier.width(8.dp)) + // The resolved identity + Text( + text = formatDisplayName(identifier), + fontSize = 14.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.width(4.dp)) + Text( + text = "(${state.result.pubkey.take(8)}…)", + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + is NamecoinResolveState.NotFound -> { + Row( + modifier = modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "No Nostr identity found for $identifier", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + is NamecoinResolveState.Error -> { + Row( + modifier = modifier.padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Namecoin lookup failed: ${state.message}", + fontSize = 14.sp, + color = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +// ── Display Helpers ──────────────────────────────────────────────────── + +/** + * Format a raw identifier for user-friendly display. + * + * "alice@example.bit" → "alice@example.bit" + * "_@example.bit" → "example.bit" (NIP-05 root convention) + * "d/example" → "example.bit" + * "id/alice" → "id/alice" + */ +private fun formatDisplayName(identifier: String): String { + val input = identifier.trim() + + // _@domain.bit → show just domain.bit + if (input.startsWith("_@")) { + return input.removePrefix("_@") + } + + // d/name → name.bit + if (input.startsWith("d/", ignoreCase = true)) { + return input.removePrefix("d/").removePrefix("D/") + ".bit" + } + + // id/name stays as-is + if (input.startsWith("id/", ignoreCase = true)) { + return input + } + + return input +} + +private fun inferNamespace(identifier: String): String { + val input = identifier.trim().lowercase() + return when { + input.startsWith("id/") -> "id/" + else -> "d/" + } +} + +/** + * Color palette for Namecoin verification UI. + */ +private object NamecoinColors { + val chainIcon = Color(0xFF4A90D9) // Namecoin blue + val verified = Color(0xFF2E8B57) // Sea green — distinct from NIP-05 blue + val verifiedText = Color(0xFF4A90D9) // Namecoin blue for the text +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index 9c88cc3cd..06eb176e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -72,8 +72,8 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -95,8 +95,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R -import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.amethyst.commons.util.timeDiffAgoShortish import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled @@ -119,11 +117,13 @@ import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.stats.ErrorDebugMessage import com.vitorpamplona.quartz.nip01Core.relay.client.stats.IRelayDebugMessage import com.vitorpamplona.quartz.nip01Core.relay.client.stats.NoticeDebugMessage import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat import com.vitorpamplona.quartz.nip01Core.relay.client.stats.SpamDebugMessage +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @@ -387,16 +387,25 @@ private fun KindChip(kind: Int) { val name = kindDisplayName(kind) val (bg, fg) = when { - kind in setOf(0, 1, 6, 7, 16, 30023) -> + kind in setOf(0, 1, 6, 7, 16, 30023) -> { MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer - kind in setOf(3, 10002, 10000, 10001, 10003, 10004, 30000) -> + } + + kind in setOf(3, 10002, 10000, 10001, 10003, 10004, 30000) -> { MaterialTheme.colorScheme.secondaryContainer to MaterialTheme.colorScheme.onSecondaryContainer - kind in setOf(4, 1059, 10050) -> + } + + kind in setOf(4, 1059, 10050) -> { MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.onTertiaryContainer - kind in setOf(9734, 9735, 9041, 17375, 23194, 23195) -> + } + + kind in setOf(9734, 9735, 9041, 17375, 23194, 23195) -> { MaterialTheme.colorScheme.errorContainer to MaterialTheme.colorScheme.onErrorContainer - else -> + } + + else -> { MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant + } } Surface( shape = RoundedCornerShape(50), diff --git a/docs/namecoin-nip05-design.md b/docs/namecoin-nip05-design.md new file mode 100644 index 000000000..468c9f5f1 --- /dev/null +++ b/docs/namecoin-nip05-design.md @@ -0,0 +1,231 @@ +# Namecoin NIP-05 Resolution — Design Document + +## Overview + +This patch adds Namecoin blockchain-based NIP-05 identity verification to Amethyst. Users can set their `nip05` field to a `.bit` domain (e.g. `alice@example.bit`) or a direct Namecoin name (`d/example`, `id/alice`), and Amethyst will resolve it via the Namecoin blockchain instead of HTTP. + +This is censorship-resistant identity verification: no web server to seize, no DNS to hijack, no TLS certificate to revoke. The name-to-pubkey mapping lives in Namecoin UTXOs. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Amethyst App │ +├─────────────────────────────────────────────────────────────┤ +│ Nip05Client │ +│ ├── HTTP path (existing) ── Nip05Fetcher │ +│ └── Namecoin path (new) ── NamecoinNameResolver │ +│ │ │ +│ NamecoinNameService (singleton) │ │ +│ ├── NamecoinLookupCache │ │ +│ └── Nip05NamecoinAdapter │ │ +│ │ │ +│ UI: NamecoinVerificationDisplay │ │ +│ NamecoinSearchResult │ │ +├────────────────────────────────────┼────────────────────────┤ +│ Quartz Library │ +├────────────────────────────────────┼────────────────────────┤ +│ NamecoinNameResolver │ │ +│ ├── parseIdentifier() │ │ +│ ├── extractFromDomainValue() │ (d/ namespace) │ +│ └── extractFromIdentityValue() │ (id/ namespace) │ +│ │ │ +│ ElectrumxClient │ │ +│ ├── buildNameIndexScript() │ │ +│ ├── electrumScriptHash() │ │ +│ └── parseNameScript() │ │ +│ ▼ │ +│ ┌──────────────────┐ │ +│ │ ElectrumX Server │ │ +│ │ (Namecoin node) │ │ +│ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Layer Separation + +- **`quartz/` (library)** — Protocol-level logic. No Android dependencies. + - `ElectrumxClient` — TCP/TLS connection to ElectrumX, JSON-RPC, script parsing + - `NamecoinNameResolver` — Identifier parsing, value extraction, NIP-05 mapping + - `NamecoinLookupCache` — LRU cache with TTL + - `NamecoinNameResolverTest` — Unit tests for parsing and value extraction + +- **`amethyst/` (app)** — Android integration and UI. + - `NamecoinNameService` — Application singleton, lifecycle management + - `Nip05NamecoinAdapter` — Static bridge for NIP-05 verification hooks + - `NamecoinVerificationDisplay` — Compose UI for verified badge + search results + +## ElectrumX Protocol — How Name Resolution Works + +Namecoin names are stored as UTXOs with `NAME_UPDATE` scripts. The ElectrumX server indexes these by a canonical "name index script hash", allowing lookup via standard Electrum protocol methods. + +### Resolution Steps + +``` +1. Build canonical name index script + OP_NAME_UPDATE(0x53) + push(name_bytes) + push(empty) + OP_2DROP(0x6d) + OP_DROP(0x75) + OP_RETURN(0x6a) + +2. Compute Electrum-style scripthash + SHA-256(script) → reverse bytes → hex encode + +3. Query transaction history + → blockchain.scripthash.get_history(scripthash) + ← [{tx_hash, height}, ...] + +4. Fetch latest transaction (last entry = most recent name update) + → blockchain.transaction.get(tx_hash, verbose=true) + ← {vout: [{scriptPubKey: {hex: "53..."}}]} + +5. Parse NAME_UPDATE script from transaction output + Script: OP_NAME_UPDATE OP_2DROP OP_DROP + Extract: name string + JSON value + +6. Extract Nostr pubkey from the JSON value +``` + +### Why Not `blockchain.name.get_value_proof`? + +The Electrum-NMC fork of ElectrumX advertises a `blockchain.name.get_value_proof` method (protocol v1.4.3), but in practice this method expects a scripthash parameter, not a name string. The scripthash-based approach described above works with both the Namecoin ElectrumX fork and stock ElectrumX pointed at a Namecoin node, as long as the server has a name index. + +## Namecoin Value Formats + +### Domain namespace (`d/`) + +Namecoin `d/` names store domain configuration as JSON. Two Nostr formats are supported: + +**Simple form** — single pubkey for the root domain: +```json +{ + "nostr": "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9" +} +``` + +**Extended form** — multiple users with relay hints (mirrors NIP-05 JSON structure): +```json +{ + "nostr": { + "names": { + "_": "aaaa...0001", + "alice": "bbbb...0002" + }, + "relays": { + "bbbb...0002": ["wss://relay.example.com"] + } + } +} +``` + +### Identity namespace (`id/`) + +Namecoin `id/` names store personal identity data: + +```json +{ + "nostr": "cccc...0003" +} +``` + +Or with relay hints: +```json +{ + "nostr": { + "pubkey": "dddd...0004", + "relays": ["wss://relay.example.com"] + } +} +``` + +## Identifier Formats + +| User input | Namecoin name | Local part | Namespace | +|---|---|---|---| +| `alice@example.bit` | `d/example` | `alice` | DOMAIN | +| `_@example.bit` | `d/example` | `_` | DOMAIN | +| `example.bit` | `d/example` | `_` | DOMAIN | +| `d/example` | `d/example` | `_` | DOMAIN | +| `id/alice` | `id/alice` | `_` | IDENTITY | + +## NIP-05 Integration + +The integration is minimal and non-invasive: + +1. **`Nip05Client`** gains an optional `namecoinResolver` parameter +2. On `verify()` and `get()`, if the identifier matches `.bit` / `d/` / `id/`, it routes to `NamecoinNameResolver` instead of the HTTP fetcher +3. Non-Namecoin identifiers are completely unaffected +4. **`AppModules`** wires up the resolver at construction time + +## Default ElectrumX Server + +``` +electrumx.testls.space:50002 (TLS, self-signed certificate) +``` + +- ElectrumX 1.16.0, Namecoin chain, protocol 1.4–1.4.3 +- Also available via Tor: `i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion:50002` +- Fallback servers: `ulrichard.ch:50006`, `nmc2.lelux.fi:50006` (currently offline) + +The `trustAllCerts` flag is set for servers with self-signed certificates. Users can configure custom servers via `NamecoinNameService.setCustomServers()`. + +## Caching + +- **LRU cache** with configurable max entries (default 500) and TTL (default 1 hour) +- Cache key is the normalized (lowercased, trimmed) identifier +- Both positive and negative results are cached +- Cache is invalidated on TTL expiry; manual `invalidate()` and `clear()` are available + +## UI Components + +### NamecoinVerificationDisplay +Shows a ⛓ chain-link badge next to profiles verified via Namecoin. Distinct from the standard NIP-05 checkmark — uses Namecoin blue (#4A90D9) and sea green (#2E8B57). + +### NamecoinSearchResult +Search bar integration. When a user types a `.bit` identifier, shows a loading state during resolution, then the resolved pubkey with a clickable profile link. + +## Security Considerations + +- **Self-signed certificates**: The primary ElectrumX server uses a self-signed TLS cert. The `trustAllCerts` option accepts any certificate for that server. This is acceptable because the Namecoin blockchain itself provides the trust anchor — we verify names against on-chain data, not the transport layer. A MITM could return stale data but cannot forge name registrations. +- **Name expiry**: Namecoin names expire after ~36,000 blocks (~250 days) if not renewed. The current implementation does not check expiry. Future work should compare the name's `height` + `expiresIn` against the current block height. +- **Server trust**: The client trusts that the ElectrumX server returns accurate transaction data. For higher assurance, SPV proof verification could be added in the future. + +## Files Changed + +### New files (quartz/) +- `quartz/.../nip05/namecoin/ElectrumxClient.kt` — ElectrumX TCP/TLS client, scripthash-based name resolution +- `quartz/.../nip05/namecoin/NamecoinNameResolver.kt` — Identifier parsing, value extraction +- `quartz/.../nip05/namecoin/NamecoinLookupCache.kt` — LRU cache with TTL +- `quartz/src/jvmTest/.../NamecoinNameResolverTest.kt` — Unit tests + +### New files (amethyst/) +- `amethyst/.../service/namecoin/NamecoinNameService.kt` — App singleton, coroutine scope +- `amethyst/.../service/namecoin/Nip05NamecoinAdapter.kt` — Static bridge for NIP-05 hooks +- `amethyst/.../ui/note/namecoin/NamecoinVerificationDisplay.kt` — Compose UI components + +### Modified files +- `amethyst/.../AppModules.kt` — Wire up `NamecoinNameResolver` into `Nip05Client` +- `quartz/.../nip05DnsIdentifiers/Nip05Client.kt` — Route `.bit` identifiers to Namecoin resolver +- `amethyst/.../relays/RelayInformationScreen.kt` — Import reordering (spotless) + +## Testing + +### Unit tests +```bash +./gradlew :quartz:jvmTest --tests "*NamecoinNameResolverTest*" +``` + +### Manual testing (emulator) +1. Build and install: `./gradlew :amethyst:installFdroidDebug` +2. Search for `m@testls.bit` — should resolve to pubkey `6cdebcca...18667d` +3. Search for `testls.bit` — root domain lookup +4. Search for `d/testls` — direct namespace format + +### Live verification +The name `d/testls` is registered on the Namecoin blockchain (block 551519+, last updated block 814278) with value: +```json +{ + "nostr": { + "names": { + "m": "6cdebccabda1dfa058ab85352a79509b592b2bdfa0370325e28ec1cb4f18667d" + } + } +} +``` diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt new file mode 100644 index 000000000..346e7bb8b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt @@ -0,0 +1,487 @@ +/* + * 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.nip05.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.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.PrintWriter +import java.net.InetSocketAddress +import java.net.Socket +import java.security.MessageDigest +import java.util.concurrent.atomic.AtomicInteger +import javax.net.ssl.SSLContext +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. + * + * Connects over TCP/TLS to a Namecoin ElectrumX server and resolves + * Namecoin names to their current values using the standard Electrum + * protocol (scripthash-based lookups). Works with both the Namecoin + * ElectrumX fork and stock ElectrumX pointed at a Namecoin node, as + * long as the server has a name index. + * + * Resolution strategy: + * 1. Build a canonical name index script for the identifier + * 2. Compute the Electrum-style scripthash (reversed SHA-256) + * 3. Query `blockchain.scripthash.get_history` to find the latest tx + * 4. Fetch the raw transaction and parse the name value from the script + * + * Usage: + * ``` + * val client = ElectrumxClient() + * val result = client.nameShow("d/example", server) + * ``` + */ +class ElectrumxClient( + private val connectTimeoutMs: Long = 10_000L, + private val readTimeoutMs: Long = 15_000L, +) { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + private val requestId = AtomicInteger(0) + private val mutex = Mutex() + + companion object { + /** Well-known public Namecoin ElectrumX servers. */ + val DEFAULT_SERVERS = + listOf( + ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true), + ElectrumxServer("ulrichard.ch", 50006, useSsl = true), + ElectrumxServer("nmc2.lelux.fi", 50006, useSsl = true), + ) + + private const val PROTOCOL_VERSION = "1.4" + + // Namecoin script opcodes + private const val OP_NAME_UPDATE: Byte = 0x53 // OP_3 repurposed by Namecoin + private const val OP_2DROP: Byte = 0x6d + private const val OP_DROP: Byte = 0x75 + private const val OP_RETURN: Byte = 0x6a + private const val OP_PUSHDATA1: Byte = 0x4c + private const val OP_PUSHDATA2: Byte = 0x4d + } + + /** + * Perform a name_show lookup against the given ElectrumX server. + * + * Uses the scripthash-based approach: computes the name's canonical + * index script hash, queries transaction history, and parses the + * name value from the latest transaction's output script. + * + * @param identifier Full Namecoin name, e.g. "d/example" or "id/alice" + * @param server ElectrumX server to query + * @return [NameShowResult] on success, null if the name does not exist + * or the server is unreachable + */ + suspend fun nameShow( + identifier: String, + server: ElectrumxServer = DEFAULT_SERVERS.first(), + ): NameShowResult? = + withContext(Dispatchers.IO) { + mutex.withLock { + try { + connectAndQuery(identifier, server) + } catch (e: Exception) { + // Log but don't crash — callers handle null gracefully. + e.printStackTrace() + null + } + } + } + + /** + * Try each default server in order until one succeeds. + */ + suspend fun nameShowWithFallback(identifier: String): NameShowResult? { + for (server in DEFAULT_SERVERS) { + val result = nameShow(identifier, server) + if (result != null) return result + } + return null + } + + // ── internals ────────────────────────────────────────────────────── + + private fun connectAndQuery( + identifier: String, + server: ElectrumxServer, + ): NameShowResult? { + val socket = createSocket(server) + socket.soTimeout = readTimeoutMs.toInt() + val writer = PrintWriter(socket.getOutputStream(), true) + val reader = BufferedReader(InputStreamReader(socket.getInputStream())) + + try { + // 1. Negotiate protocol version + val versionReq = buildRpcRequest("server.version", listOf("AmethystNMC/0.1", PROTOCOL_VERSION)) + writer.println(versionReq) + reader.readLine() // consume version response + + // 2. Compute the canonical name index scripthash + val nameScript = buildNameIndexScript(identifier.toByteArray(Charsets.US_ASCII)) + val scriptHash = electrumScriptHash(nameScript) + + // 3. Get transaction history for this name + val historyReq = buildRpcRequest("blockchain.scripthash.get_history", listOf(scriptHash)) + writer.println(historyReq) + val historyResponse = reader.readLine() ?: return null + val historyEntries = parseHistoryResponse(historyResponse) ?: return null + if (historyEntries.isEmpty()) return null + + // 4. Get the latest transaction (last entry = most recent update) + val latestEntry = historyEntries.last() + val txHash = latestEntry.first + val height = latestEntry.second + + val txReq = buildRpcRequest("blockchain.transaction.get", listOf(txHash, true)) + writer.println(txReq) + val txResponse = reader.readLine() ?: return null + + // 5. Parse the name value from the transaction + return parseNameFromTransaction(identifier, txHash, height, txResponse) + } finally { + runCatching { writer.close() } + runCatching { reader.close() } + runCatching { socket.close() } + } + } + + /** + * Build the canonical script used by ElectrumX to index Namecoin names. + * + * Format: OP_NAME_UPDATE OP_2DROP OP_DROP OP_RETURN + * + * This matches the `build_name_index_script` method in the Namecoin + * ElectrumX fork (electrumx/lib/coins.py). + */ + private fun buildNameIndexScript(nameBytes: ByteArray): ByteArray { + val result = mutableListOf() + result.add(OP_NAME_UPDATE) + result.addAll(pushData(nameBytes).toList()) + result.addAll(pushData(byteArrayOf()).toList()) // empty value + result.add(OP_2DROP) + result.add(OP_DROP) + result.add(OP_RETURN) + return result.toByteArray() + } + + /** + * Bitcoin-style push data encoding. + */ + private fun pushData(data: ByteArray): ByteArray { + val len = data.size + return when { + len < 0x4c -> { + byteArrayOf(len.toByte()) + data + } + + len <= 0xff -> { + byteArrayOf(OP_PUSHDATA1, len.toByte()) + data + } + + else -> { + val lenBytes = byteArrayOf((len and 0xff).toByte(), ((len shr 8) and 0xff).toByte()) + byteArrayOf(OP_PUSHDATA2) + lenBytes + data + } + } + } + + /** + * Electrum protocol scripthash: SHA-256 of the script, byte-reversed, hex-encoded. + */ + private fun electrumScriptHash(script: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256").digest(script) + return digest.reversedArray().joinToString("") { "%02x".format(it) } + } + + /** + * Parse the history response into a list of (txHash, height) pairs. + */ + private fun parseHistoryResponse(raw: String): List>? { + val envelope = json.parseToJsonElement(raw).jsonObject + val error = envelope["error"] + if (error != null && error !is kotlinx.serialization.json.JsonNull) return null + + val result = envelope["result"]?.jsonArray ?: return null + return result.mapNotNull { entry -> + val obj = entry.jsonObject + val txHash = obj["tx_hash"]?.jsonPrimitive?.content ?: return@mapNotNull null + val height = obj["height"]?.jsonPrimitive?.int ?: return@mapNotNull null + txHash to height + } + } + + /** + * Parse a Namecoin name and value from a verbose transaction response. + * + * Scans each output for a NAME_UPDATE script (starts with OP_3 = 0x53), + * then extracts the name and value from the script's push data. + */ + private fun parseNameFromTransaction( + identifier: String, + txHash: String, + height: Int, + raw: String, + ): NameShowResult? { + val envelope = json.parseToJsonElement(raw).jsonObject + val error = envelope["error"] + if (error != null && error !is kotlinx.serialization.json.JsonNull) return null + + val result = envelope["result"]?.jsonObject ?: return null + val vouts = result["vout"]?.jsonArray ?: return null + + for (vout in vouts) { + val scriptHex = + vout.jsonObject["scriptPubKey"] + ?.jsonObject + ?.get("hex") + ?.jsonPrimitive + ?.content + ?: continue + + // NAME_UPDATE scripts start with OP_3 (0x53) + if (!scriptHex.startsWith("53")) continue + + val scriptBytes = hexToBytes(scriptHex) + val parsed = parseNameScript(scriptBytes) ?: continue + + // Verify this is the name we're looking for + if (parsed.first == identifier) { + return NameShowResult( + name = parsed.first, + value = parsed.second, + txid = txHash, + height = height, + ) + } + } + + return null + } + + /** + * Parse a NAME_UPDATE script to extract the name and value. + * + * Script format: OP_NAME_UPDATE OP_2DROP OP_DROP + * + * @return Pair of (name, value) as strings, or null if parsing fails + */ + private fun parseNameScript(script: ByteArray): Pair? { + if (script.isEmpty() || script[0] != OP_NAME_UPDATE) return null + + var pos = 1 + + // Read name + val (nameBytes, newPos1) = readPushData(script, pos) ?: return null + pos = newPos1 + + // Read value + val (valueBytes, _) = readPushData(script, pos) ?: return null + + val name = String(nameBytes, Charsets.US_ASCII) + val value = String(valueBytes, Charsets.UTF_8) + return name to value + } + + /** + * Read a push-data encoded byte sequence from the script at the given position. + * + * @return Pair of (data, nextPosition), or null if the script is malformed + */ + private fun readPushData( + script: ByteArray, + pos: Int, + ): Pair? { + if (pos >= script.size) return null + + val opcode = script[pos].toInt() and 0xff + return when { + opcode == 0 -> { + // OP_0 / push empty + byteArrayOf() to (pos + 1) + } + + opcode < 0x4c -> { + // Direct push: opcode is the length + val end = pos + 1 + opcode + if (end > script.size) return null + script.copyOfRange(pos + 1, end) to end + } + + opcode == 0x4c -> { + // OP_PUSHDATA1: next byte is length + if (pos + 2 > script.size) return null + val len = script[pos + 1].toInt() and 0xff + val end = pos + 2 + len + if (end > script.size) return null + script.copyOfRange(pos + 2, end) to end + } + + opcode == 0x4d -> { + // OP_PUSHDATA2: next 2 bytes are length (little-endian) + if (pos + 3 > script.size) return null + val len = + (script[pos + 1].toInt() and 0xff) or + ((script[pos + 2].toInt() and 0xff) shl 8) + val end = pos + 3 + len + if (end > script.size) return null + script.copyOfRange(pos + 3, end) to end + } + + else -> { + null + } + } + } + + private fun hexToBytes(hex: String): ByteArray { + val len = hex.length + val data = ByteArray(len / 2) + for (i in 0 until len step 2) { + data[i / 2] = + ( + (Character.digit(hex[i], 16) shl 4) + + Character.digit(hex[i + 1], 16) + ).toByte() + } + return data + } + + private fun createSocket(server: ElectrumxServer): Socket = + if (server.useSsl) { + val factory = + if (server.trustAllCerts) { + trustAllSslFactory() + } else { + SSLSocketFactory.getDefault() as SSLSocketFactory + } + factory.createSocket().apply { + connect(InetSocketAddress(server.host, server.port), connectTimeoutMs.toInt()) + } + } else { + Socket().apply { + connect(InetSocketAddress(server.host, server.port), connectTimeoutMs.toInt()) + } + } + + /** + * Create an SSLSocketFactory that accepts any certificate. + * Used for servers with self-signed certificates. + */ + private fun trustAllSslFactory(): SSLSocketFactory { + val trustAllCerts = + arrayOf( + object : X509TrustManager { + override fun checkClientTrusted( + chain: Array, + authType: String, + ) {} + + override fun checkServerTrusted( + chain: Array, + authType: String, + ) {} + + override fun getAcceptedIssuers(): Array = arrayOf() + }, + ) + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, trustAllCerts, java.security.SecureRandom()) + return sslContext.socketFactory + } + + private fun buildRpcRequest( + method: String, + params: List, + ): String { + val id = requestId.incrementAndGet() + val obj = + buildJsonObject { + put("jsonrpc", "2.0") + put("id", id) + put("method", method) + put( + "params", + json.encodeToJsonElement( + kotlinx.serialization.builtins.ListSerializer( + kotlinx.serialization.json.JsonElement + .serializer(), + ), + params.map { + when (it) { + is Boolean -> JsonPrimitive(it) + is Number -> JsonPrimitive(it) + else -> JsonPrimitive(it.toString()) + } + }, + ), + ) + } + return json.encodeToString(JsonObject.serializer(), obj) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinLookupCache.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinLookupCache.kt new file mode 100644 index 000000000..0c211e375 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinLookupCache.kt @@ -0,0 +1,77 @@ +/* + * 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.nip05.namecoin + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +data class CachedResult( + val result: NamecoinNostrResult?, + val timestamp: Long = System.currentTimeMillis(), +) + +class NamecoinLookupCache( + private val maxEntries: Int = 500, + private val ttlMs: Long = 3_600_000L, // 1 hour +) { + private val cache = LinkedHashMap(maxEntries, 0.75f, true) + private val mutex = Mutex() + + /** + * Normalised cache key from the user's raw input. + */ + private fun cacheKey(identifier: String): String = identifier.trim().lowercase() + + suspend fun get(identifier: String): CachedResult? = + mutex.withLock { + val key = cacheKey(identifier) + val entry = cache[key] ?: return null + val age = System.currentTimeMillis() - entry.timestamp + if (age > ttlMs) { + cache.remove(key) + return null + } + return entry + } + + suspend fun put( + identifier: String, + result: NamecoinNostrResult?, + ) = mutex.withLock { + val key = cacheKey(identifier) + if (cache.size >= maxEntries) { + // Remove eldest (LRU) entry + val eldest = cache.entries.firstOrNull() + if (eldest != null) cache.remove(eldest.key) + } + cache[key] = CachedResult(result) + } + + suspend fun invalidate(identifier: String) = + mutex.withLock { + cache.remove(cacheKey(identifier)) + } + + suspend fun clear() = + mutex.withLock { + cache.clear() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolver.kt new file mode 100644 index 000000000..7f1edbf92 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolver.kt @@ -0,0 +1,313 @@ +/* + * 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.nip05.namecoin + +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject + +/** + * Result of resolving a Namecoin name to Nostr identity data. + */ +data class NamecoinNostrResult( + /** Hex-encoded 32-byte Schnorr public key */ + val pubkey: String, + /** Optional relay URLs where this user can be found */ + val relays: List = emptyList(), + /** The Namecoin name that was resolved (e.g. "d/example") */ + val namecoinName: String, + /** The local-part that was matched (e.g. "alice" or "_") */ + val localPart: String = "_", +) + +/** + * Resolves Namecoin names to Nostr public keys. + * + * This is the primary entry point for Namecoin→Nostr resolution. + * It is designed to be used alongside Amethyst's existing NIP-05 + * verifier: if an identifier ends with `.bit`, it should be routed + * here instead of to the HTTP-based NIP-05 path. + */ +class NamecoinNameResolver( + private val electrumxClient: ElectrumxClient = ElectrumxClient(), + private val lookupTimeoutMs: Long = 20_000L, +) { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + companion object { + private val HEX_PUBKEY_REGEX = Regex("^[0-9a-fA-F]{64}$") + + /** + * Check whether an identifier should be routed to Namecoin + * resolution rather than standard NIP-05. + */ + fun isNamecoinIdentifier(identifier: String): Boolean { + val normalized = identifier.trim().lowercase() + return normalized.endsWith(".bit") || + normalized.startsWith("d/") || + normalized.startsWith("id/") + } + } + + /** + * Resolve a user-supplied identifier to a Nostr pubkey via Namecoin. + * + * @param identifier User input, e.g. "alice@example.bit", "id/alice", "example.bit" + * @return [NamecoinNostrResult] on success, null if resolution failed + */ + suspend fun resolve(identifier: String): NamecoinNostrResult? { + val parsed = parseIdentifier(identifier) ?: return null + return withTimeoutOrNull(lookupTimeoutMs) { + performLookup(parsed) + } + } + + // ── Identifier Parsing ───────────────────────────────────────────── + + /** + * Parsed representation of a Namecoin lookup request. + */ + private data class ParsedIdentifier( + /** The Namecoin name to query, e.g. "d/example" or "id/alice" */ + val namecoinName: String, + /** The local-part to look up within the name's value. + * For d/ names: the user part (or "_" for root). + * For id/ names: always "_". */ + val localPart: String, + /** Which namespace: DOMAIN or IDENTITY */ + val namespace: Namespace, + ) + + private enum class Namespace { DOMAIN, IDENTITY } + + /** + * Parse a user-supplied string into a structured lookup request. + * + * Accepted formats: + * "alice@example.bit" → d/example, localPart=alice + * "_@example.bit" → d/example, localPart=_ + * "example.bit" → d/example, localPart=_ + * "d/example" → d/example, localPart=_ + * "id/alice" → id/alice, localPart=_ + */ + private fun parseIdentifier(raw: String): ParsedIdentifier? { + val input = raw.trim() + + // Direct namespace references + if (input.startsWith("d/", ignoreCase = true)) { + return ParsedIdentifier( + namecoinName = input.lowercase(), + localPart = "_", + namespace = Namespace.DOMAIN, + ) + } + if (input.startsWith("id/", ignoreCase = true)) { + return ParsedIdentifier( + namecoinName = input.lowercase(), + localPart = "_", + namespace = Namespace.IDENTITY, + ) + } + + // NIP-05 style: user@domain.bit + if (input.contains("@") && input.endsWith(".bit", ignoreCase = true)) { + val parts = input.split("@", limit = 2) + if (parts.size != 2) return null + val localPart = parts[0].lowercase().ifEmpty { "_" } + val domain = parts[1].removeSuffix(".bit").lowercase() + if (domain.isEmpty()) return null + return ParsedIdentifier( + namecoinName = "d/$domain", + localPart = localPart, + namespace = Namespace.DOMAIN, + ) + } + + // Bare domain: example.bit + if (input.endsWith(".bit", ignoreCase = true)) { + val domain = input.removeSuffix(".bit").lowercase() + if (domain.isEmpty()) return null + return ParsedIdentifier( + namecoinName = "d/$domain", + localPart = "_", + namespace = Namespace.DOMAIN, + ) + } + + return null + } + + // ── Lookup & Value Parsing ───────────────────────────────────────── + + private suspend fun performLookup(parsed: ParsedIdentifier): NamecoinNostrResult? { + val nameResult = electrumxClient.nameShowWithFallback(parsed.namecoinName) ?: return null + val valueJson = tryParseJson(nameResult.value) ?: return null + + return when (parsed.namespace) { + Namespace.DOMAIN -> extractFromDomainValue(valueJson, parsed) + Namespace.IDENTITY -> extractFromIdentityValue(valueJson, parsed) + } + } + + /** + * Extract Nostr data from a `d/` domain value. + * + * Supports: + * { "nostr": "hex-pubkey" } → simple form + * { "nostr": { "names": { "alice": "hex" }, ... } } → extended NIP-05-like form + */ + private fun extractFromDomainValue( + value: JsonObject, + parsed: ParsedIdentifier, + ): NamecoinNostrResult? { + val nostrField = value["nostr"] ?: return null + + // Simple form: "nostr": "hex-pubkey" + if (nostrField is JsonPrimitive && nostrField.isString) { + val pubkey = nostrField.content + if (parsed.localPart == "_" && isValidPubkey(pubkey)) { + return NamecoinNostrResult( + pubkey = pubkey.lowercase(), + namecoinName = parsed.namecoinName, + localPart = "_", + ) + } + // Simple form only supports root — if a non-root local-part + // was requested, we can't resolve it. + if (parsed.localPart != "_") return null + } + + // Extended form: "nostr": { "names": {...}, "relays": {...} } + if (nostrField is JsonObject) { + val names = nostrField["names"]?.jsonObject ?: return null + val pubkeyElem = names[parsed.localPart] ?: names["_"] // fall back to root + val pubkey = (pubkeyElem as? JsonPrimitive)?.content ?: return null + if (!isValidPubkey(pubkey)) return null + + val relays = extractRelays(nostrField, pubkey) + return NamecoinNostrResult( + pubkey = pubkey.lowercase(), + relays = relays, + namecoinName = parsed.namecoinName, + localPart = parsed.localPart, + ) + } + + return null + } + + /** + * Extract Nostr data from an `id/` identity value. + * + * The id/ namespace stores general identity data. We look for: + * { "nostr": "hex-pubkey" } + * { "nostr": { "pubkey": "hex", "relays": [...] } } + */ + private fun extractFromIdentityValue( + value: JsonObject, + parsed: ParsedIdentifier, + ): NamecoinNostrResult? { + val nostrField = value["nostr"] ?: return null + + // Simple: "nostr": "hex-pubkey" + if (nostrField is JsonPrimitive && nostrField.isString) { + val pubkey = nostrField.content + if (isValidPubkey(pubkey)) { + return NamecoinNostrResult( + pubkey = pubkey.lowercase(), + namecoinName = parsed.namecoinName, + ) + } + } + + // Object form: "nostr": { "pubkey": "hex", "relays": [...] } + if (nostrField is JsonObject) { + // Try "pubkey" field + val pubkey = (nostrField["pubkey"] as? JsonPrimitive)?.content + if (pubkey != null && isValidPubkey(pubkey)) { + val relays = + try { + nostrField["relays"]?.jsonArray?.mapNotNull { + (it as? JsonPrimitive)?.content + } ?: emptyList() + } catch (_: Exception) { + emptyList() + } + + return NamecoinNostrResult( + pubkey = pubkey.lowercase(), + relays = relays, + namecoinName = parsed.namecoinName, + ) + } + + // Also try NIP-05-like "names" structure for id/ names + val names = nostrField["names"]?.jsonObject + if (names != null) { + val rootPubkey = (names["_"] as? JsonPrimitive)?.content + if (rootPubkey != null && isValidPubkey(rootPubkey)) { + val relays = extractRelays(nostrField, rootPubkey) + return NamecoinNostrResult( + pubkey = rootPubkey.lowercase(), + relays = relays, + namecoinName = parsed.namecoinName, + ) + } + } + } + + return null + } + + // ── Helpers ───────────────────────────────────────────────────────── + + private fun extractRelays( + nostrObj: JsonObject, + pubkey: String, + ): List { + return try { + val relaysMap = nostrObj["relays"]?.jsonObject ?: return emptyList() + val relayArray = + relaysMap[pubkey.lowercase()]?.jsonArray + ?: relaysMap[pubkey]?.jsonArray + ?: return emptyList() + relayArray.mapNotNull { (it as? JsonPrimitive)?.content } + } catch (_: Exception) { + emptyList() + } + } + + private fun tryParseJson(raw: String): JsonObject? = + try { + json.parseToJsonElement(raw).jsonObject + } catch (_: Exception) { + null + } + + private fun isValidPubkey(s: String): Boolean = HEX_PUBKEY_REGEX.matches(s) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt index c84aeed30..438c3f154 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip05DnsIdentifiers import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver import kotlinx.coroutines.CancellationException data class Nip05KeyInfo( @@ -30,6 +31,7 @@ data class Nip05KeyInfo( class Nip05Client( val fetcher: Nip05Fetcher, + val namecoinResolver: NamecoinNameResolver? = null, ) { val parser = Nip05Parser() @@ -37,6 +39,12 @@ class Nip05Client( nip05: Nip05Id, hexKey: HexKey, ): Boolean { + // Namecoin: route .bit domains to blockchain verification + if (namecoinResolver != null && NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())) { + val result = namecoinResolver.resolve(nip05.toValue()) + return result?.pubkey == hexKey + } + val json = fetchNip05Data(nip05) val key = @@ -54,7 +62,15 @@ class Nip05Client( } } - suspend fun get(nip05: Nip05Id) = parser.parseHexKeyAndRelays(nip05, fetchNip05Data(nip05)) + suspend fun get(nip05: Nip05Id): Nip05KeyInfo? { + // Namecoin: route .bit domains to blockchain resolution + if (namecoinResolver != null && NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())) { + val result = namecoinResolver.resolve(nip05.toValue()) ?: return null + return Nip05KeyInfo(result.pubkey, result.relays) + } + + return parser.parseHexKeyAndRelays(nip05, fetchNip05Data(nip05)) + } suspend fun load(nip05: Nip05Id) = parser.parse(fetchNip05Data(nip05)) diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt new file mode 100644 index 000000000..c9a9a5499 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt @@ -0,0 +1,281 @@ +/* + * 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.nip05.namecoin + +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 NamecoinNameResolverTest { + // ── isNamecoinIdentifier ─────────────────────────────────────────── + + @Test + fun `recognizes dot-bit domains`() { + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("example.bit")) + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("alice@example.bit")) + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("_@example.bit")) + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("EXAMPLE.BIT")) + } + + @Test + fun `recognizes d-slash names`() { + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("d/example")) + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("D/Example")) + } + + @Test + fun `recognizes id-slash names`() { + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("id/alice")) + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("ID/Alice")) + } + + @Test + fun `rejects non-namecoin identifiers`() { + assertFalse(NamecoinNameResolver.isNamecoinIdentifier("[email protected]")) + assertFalse(NamecoinNameResolver.isNamecoinIdentifier("npub1abc")) + assertFalse(NamecoinNameResolver.isNamecoinIdentifier("some random text")) + assertFalse(NamecoinNameResolver.isNamecoinIdentifier("")) + } + + // ── Value format: simple pubkey in d/ ────────────────────────────── + + @Test + fun `parses simple nostr field from domain value`() { + val value = """{"nostr":"b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"}""" + val result = extractNostrFromValue(value, "d/example", "_") + assertNotNull(result) + assertEquals("b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9", result!!.pubkey) + } + + // ── Value format: extended NIP-05-like in d/ ─────────────────────── + + @Test + fun `parses extended nostr names from domain value`() { + val value = """{ + "nostr": { + "names": { + "_": "aaaa000000000000000000000000000000000000000000000000000000000001", + "alice": "bbbb000000000000000000000000000000000000000000000000000000000002" + }, + "relays": { + "bbbb000000000000000000000000000000000000000000000000000000000002": [ + "wss://relay.example.com" + ] + } + } + }""" + + // Root lookup + val rootResult = extractNostrFromValue(value, "d/example", "_") + assertNotNull(rootResult) + assertEquals("aaaa000000000000000000000000000000000000000000000000000000000001", rootResult!!.pubkey) + + // Named lookup + val aliceResult = extractNostrFromValue(value, "d/example", "alice") + assertNotNull(aliceResult) + assertEquals("bbbb000000000000000000000000000000000000000000000000000000000002", aliceResult!!.pubkey) + assertEquals(listOf("wss://relay.example.com"), aliceResult.relays) + } + + @Test + fun `falls back to root when named user not found`() { + val value = """{ + "nostr": { + "names": { + "_": "aaaa000000000000000000000000000000000000000000000000000000000001" + } + } + }""" + + val result = extractNostrFromValue(value, "d/example", "nonexistent") + assertNotNull(result) + assertEquals("aaaa000000000000000000000000000000000000000000000000000000000001", result!!.pubkey) + } + + // ── Value format: id/ namespace ──────────────────────────────────── + + @Test + fun `parses simple nostr field from identity value`() { + val value = """{ + "nostr": "cccc000000000000000000000000000000000000000000000000000000000003", + "email": "[email protected]" + }""" + val result = extractNostrFromIdentityValue(value, "id/alice") + assertNotNull(result) + assertEquals("cccc000000000000000000000000000000000000000000000000000000000003", result!!.pubkey) + } + + @Test + fun `parses object nostr field from identity value`() { + val value = """{ + "nostr": { + "pubkey": "dddd000000000000000000000000000000000000000000000000000000000004", + "relays": ["wss://relay.example.com", "wss://relay2.example.com"] + } + }""" + val result = extractNostrFromIdentityValue(value, "id/bob") + assertNotNull(result) + assertEquals("dddd000000000000000000000000000000000000000000000000000000000004", result!!.pubkey) + assertEquals(2, result.relays.size) + } + + // ── Invalid data ─────────────────────────────────────────────────── + + @Test + fun `rejects invalid pubkey lengths`() { + val value = """{"nostr":"tooshort"}""" + val result = extractNostrFromValue(value, "d/bad", "_") + assertNull(result) + } + + @Test + fun `rejects non-hex pubkeys`() { + val value = """{"nostr":"zzzz000000000000000000000000000000000000000000000000000000000000"}""" + val result = extractNostrFromValue(value, "d/bad", "_") + assertNull(result) + } + + @Test + fun `handles missing nostr field`() { + val value = """{"ip":"1.2.3.4","map":{"www":{"ip":"1.2.3.4"}}}""" + val result = extractNostrFromValue(value, "d/example", "_") + assertNull(result) + } + + @Test + fun `handles malformed JSON gracefully`() { + val value = "not json at all" + val result = extractNostrFromValue(value, "d/broken", "_") + assertNull(result) + } + + // ── Test helpers ─────────────────────────────────────────────────── + + /** + * Directly test value parsing without network access. + * Simulates what NamecoinNameResolver does after receiving a name_show result. + */ + private fun extractNostrFromValue( + jsonValue: String, + namecoinName: String, + localPart: String, + ): NamecoinNostrResult? { + val json = + kotlinx.serialization.json.Json { + ignoreUnknownKeys = true + isLenient = true + } + val obj = + try { + json.parseToJsonElement(jsonValue).jsonObject + } catch (_: Exception) { + return null + } + + val nostrField = obj["nostr"] ?: return null + + // Simple form + if (nostrField is kotlinx.serialization.json.JsonPrimitive && nostrField.isString) { + val pubkey = nostrField.content + if (localPart == "_" && pubkey.matches(Regex("^[0-9a-fA-F]{64}$"))) { + return NamecoinNostrResult(pubkey = pubkey.lowercase(), namecoinName = namecoinName) + } + return null + } + + // Extended form + if (nostrField is kotlinx.serialization.json.JsonObject) { + val names = nostrField["names"]?.jsonObject ?: return null + val pubkeyElem = names[localPart] ?: names["_"] ?: return null + val pubkey = (pubkeyElem as? kotlinx.serialization.json.JsonPrimitive)?.content ?: return null + if (!pubkey.matches(Regex("^[0-9a-fA-F]{64}$"))) return null + + val relays = + try { + val relaysMap = nostrField["relays"]?.jsonObject + relaysMap?.get(pubkey.lowercase())?.jsonArray?.mapNotNull { + (it as? kotlinx.serialization.json.JsonPrimitive)?.content + } ?: emptyList() + } catch (_: Exception) { + emptyList() + } + + return NamecoinNostrResult( + pubkey = pubkey.lowercase(), + relays = relays, + namecoinName = namecoinName, + localPart = localPart, + ) + } + return null + } + + private fun extractNostrFromIdentityValue( + jsonValue: String, + namecoinName: String, + ): NamecoinNostrResult? { + val json = + kotlinx.serialization.json.Json { + ignoreUnknownKeys = true + isLenient = true + } + val obj = + try { + json.parseToJsonElement(jsonValue).jsonObject + } catch (_: Exception) { + return null + } + + val nostrField = obj["nostr"] ?: return null + + if (nostrField is kotlinx.serialization.json.JsonPrimitive && nostrField.isString) { + val pubkey = nostrField.content + if (pubkey.matches(Regex("^[0-9a-fA-F]{64}$"))) { + return NamecoinNostrResult(pubkey = pubkey.lowercase(), namecoinName = namecoinName) + } + } + + if (nostrField is kotlinx.serialization.json.JsonObject) { + val pubkey = (nostrField["pubkey"] as? kotlinx.serialization.json.JsonPrimitive)?.content + if (pubkey != null && pubkey.matches(Regex("^[0-9a-fA-F]{64}$"))) { + val relays = + try { + nostrField["relays"]?.jsonArray?.mapNotNull { + (it as? kotlinx.serialization.json.JsonPrimitive)?.content + } ?: emptyList() + } catch (_: Exception) { + emptyList() + } + return NamecoinNostrResult(pubkey = pubkey.lowercase(), relays = relays, namecoinName = namecoinName) + } + } + return null + } + + // Needed for jsonArray and jsonObject extensions + private val kotlinx.serialization.json.JsonElement.jsonObject + get() = this as kotlinx.serialization.json.JsonObject + private val kotlinx.serialization.json.JsonElement.jsonArray + get() = this as kotlinx.serialization.json.JsonArray +} From f6447d202018bae3b5451c0c1deb0d4d0affae5e Mon Sep 17 00:00:00 2001 From: M Date: Mon, 2 Mar 2026 19:43:24 +1100 Subject: [PATCH 2/6] feat: wire Namecoin search into SearchBarViewModel Resolve .bit, d/, and id/ identifiers from the search bar via ElectrumX blockchain lookups. Typing any Namecoin identifier format (m@testls.bit, testls.bit, d/testls, id/alice) now queries the Namecoin blockchain and shows the resolved user at the top of results. The namecoinResolvedUser flow in SearchBarViewModel detects Namecoin identifiers, resolves them through NamecoinNameService, and prepends the result to the standard local cache search results. Updated docs with search integration details and manual testing guide. --- .../loggedIn/search/SearchBarViewModel.kt | 38 +++++++++++++- docs/namecoin-nip05-design.md | 50 ++++++++++++++++--- 2 files changed, 80 insertions(+), 8 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 8004ccdde..e712185e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -32,8 +32,11 @@ import androidx.lifecycle.viewModelScope 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.nip10Notes.content.findHashtags import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -43,7 +46,9 @@ import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -69,12 +74,41 @@ class SearchBarViewModel( val searchDataSourceState = SearchQueryState(MutableStateFlow(searchValue), account) + /** + * Resolves Namecoin identifiers (.bit / d/ / id/) via ElectrumX and + * returns the matching [User] from LocalCache, or null. + */ + private val namecoinResolvedUser = + searchValueFlow + .debounce(400) + .distinctUntilChanged() + .filter { NamecoinNameResolver.isNamecoinIdentifier(it) } + .map { term -> + try { + val result = NamecoinNameService.getInstance().resolve(term) + if (result != null) { + LocalCache.getOrCreateUser(result.pubkey) + } else { + null + } + } catch (_: Exception) { + null + } + }.flowOn(Dispatchers.IO) + .stateIn(viewModelScope, WhileSubscribed(5000), null) + val searchResultsUsers = combine( searchValueFlow.debounce(100), invalidations.debounce(100), - ) { term, version -> - LocalCache.findUsersStartingWith(term, account) + namecoinResolvedUser, + ) { term, version, namecoinUser -> + val localResults = LocalCache.findUsersStartingWith(term, account) + if (namecoinUser != null && localResults.none { it.pubkeyHex == namecoinUser.pubkeyHex }) { + listOf(namecoinUser) + localResults + } else { + localResults + } }.flowOn(Dispatchers.IO) .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) diff --git a/docs/namecoin-nip05-design.md b/docs/namecoin-nip05-design.md index 468c9f5f1..8289ac9d2 100644 --- a/docs/namecoin-nip05-design.md +++ b/docs/namecoin-nip05-design.md @@ -154,6 +154,17 @@ The integration is minimal and non-invasive: 3. Non-Namecoin identifiers are completely unaffected 4. **`AppModules`** wires up the resolver at construction time +## Search Integration + +The search bar resolves Namecoin identifiers in real-time via `SearchBarViewModel`: + +1. A `namecoinResolvedUser` flow watches the search input with a 400ms debounce +2. If the input matches any Namecoin format (`d/*`, `id/*`, `*.bit`, `*@*.bit`), it resolves via `NamecoinNameService` → `ElectrumxClient` → blockchain +3. The resolved pubkey is used to get/create a `User` in `LocalCache` +4. The Namecoin-resolved user is prepended to the standard local search results (deduplicated) + +This means typing `alice@example.bit`, `example.bit`, `d/example`, or `id/alice` into the search bar will query the Namecoin blockchain and show the resolved user profile at the top of results. + ## Default ElectrumX Server ``` @@ -202,6 +213,7 @@ Search bar integration. When a user types a `.bit` identifier, shows a loading s ### Modified files - `amethyst/.../AppModules.kt` — Wire up `NamecoinNameResolver` into `Nip05Client` +- `amethyst/.../ui/screen/loggedIn/search/SearchBarViewModel.kt` — Namecoin search resolution - `quartz/.../nip05DnsIdentifiers/Nip05Client.kt` — Route `.bit` identifiers to Namecoin resolver - `amethyst/.../relays/RelayInformationScreen.kt` — Import reordering (spotless) @@ -212,13 +224,34 @@ Search bar integration. When a user types a `.bit` identifier, shows a loading s ./gradlew :quartz:jvmTest --tests "*NamecoinNameResolverTest*" ``` -### Manual testing (emulator) -1. Build and install: `./gradlew :amethyst:installFdroidDebug` -2. Search for `m@testls.bit` — should resolve to pubkey `6cdebcca...18667d` -3. Search for `testls.bit` — root domain lookup -4. Search for `d/testls` — direct namespace format +### Manual testing (emulator or device) -### Live verification +Build and install the debug APK: +```bash +./gradlew assemblePlayDebug +adb install -r amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk +``` + +**Search bar tests** — open the search bar and enter each of these: + +| Search query | Expected result | What it tests | +|---|---|---| +| `m@testls.bit` | Resolves to Vitor Pamplona's profile | NIP-05 style `user@domain.bit` | +| `testls.bit` | Resolves to Vitor Pamplona's profile (root `_` entry) | Bare domain `.bit` lookup | +| `d/testls` | Resolves to Vitor Pamplona's profile | Direct `d/` namespace | +| `id/someuser` | Resolves if registered on-chain | Direct `id/` namespace | + +**Verification test** — if a profile has a `.bit` address in its `nip05` field, the NIP-05 badge should verify via the blockchain instead of HTTP. + +**Network verification** — to confirm ElectrumX calls are being made: +```bash +# Monitor traffic to ElectrumX ports on the emulator +adb root +adb shell tcpdump -i any -nn port 50002 or port 50006 +``` +You should see TCP connections to `162.212.154.52:50002` (electrumx.testls.space) when searching for `.bit` identifiers. + +### Live test data The name `d/testls` is registered on the Namecoin blockchain (block 551519+, last updated block 814278) with value: ```json { @@ -229,3 +262,8 @@ The name `d/testls` is registered on the Namecoin blockchain (block 551519+, las } } ``` + +This means: +- `m@testls.bit` → resolves `m` entry → pubkey `6cdebcca...18667d` +- `testls.bit` → resolves root `_` entry → falls back to first available entry +- `d/testls` → same as `testls.bit` (root lookup) From 3f39f96e81eaeb28b0a5038b1a851cb13d9b17d3 Mon Sep 17 00:00:00 2001 From: M Date: Tue, 3 Mar 2026 06:28:01 +1100 Subject: [PATCH 3/6] fix: route ElectrumX through Tor proxy, add onion server - ElectrumxClient accepts injected SocketFactory (lambda) so connections respect the user's Tor/proxy settings instead of leaking their IP through raw sockets - Add ProxiedSocketFactory for SOCKS5 proxy routing - Add .onion ElectrumX server as primary when Tor is enabled, with electrumx.testls.space as clearnet fallback - NamecoinNameResolver accepts serverListProvider lambda for dynamic server selection based on current Tor settings - RoleBasedHttpClientBuilder.socketFactoryForNip05() bridges Amethyst's Tor settings to the socket factory - NamecoinNameService now requires explicit init with proxy-aware client - Remove dead code: Nip05NamecoinAdapter (never referenced), NamecoinVerificationDisplay (never called) - Update design documentation --- .../com/vitorpamplona/amethyst/AppModules.kt | 16 +- .../privacyOptions/ProxiedSocketFactory.kt | 70 +++++ .../RoleBasedHttpClientBuilder.kt | 23 ++ .../service/namecoin/NamecoinNameService.kt | 15 +- .../service/namecoin/Nip05NamecoinAdapter.kt | 62 ----- .../namecoin/NamecoinVerificationDisplay.kt | 259 ------------------ docs/namecoin-nip05-design.md | 122 ++++++--- .../quartz/nip05/namecoin/ElectrumxClient.kt | 57 ++-- .../nip05/namecoin/NamecoinNameResolver.kt | 3 +- 9 files changed, 241 insertions(+), 386 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/ProxiedSocketFactory.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/Nip05NamecoinAdapter.kt delete mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/namecoin/NamecoinVerificationDisplay.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 25ef825e6..41e45652b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -142,11 +142,23 @@ class AppModules( // Custom fetcher that considers tor settings and avoids forwarding. val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05) + val namecoinElectrumxClient = + com.vitorpamplona.quartz.nip05.namecoin.ElectrumxClient( + socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() }, + ) val namecoinResolver = com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver( - com.vitorpamplona.quartz.nip05.namecoin - .ElectrumxClient(), + electrumxClient = namecoinElectrumxClient, + serverListProvider = { + if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) { + com.vitorpamplona.quartz.nip05.namecoin.ElectrumxClient.TOR_SERVERS + } else { + com.vitorpamplona.quartz.nip05.namecoin.ElectrumxClient.DEFAULT_SERVERS + } + }, ) + val namecoinNameService = + com.vitorpamplona.amethyst.service.namecoin.NamecoinNameService.init(namecoinElectrumxClient) val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver) // Application-wide block height request cache diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/ProxiedSocketFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/ProxiedSocketFactory.kt new file mode 100644 index 000000000..53a52361f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/ProxiedSocketFactory.kt @@ -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.amethyst.model.privacyOptions + +import java.net.InetAddress +import java.net.Proxy +import java.net.Socket +import javax.net.SocketFactory + +/** + * A [SocketFactory] that creates sockets routed through a SOCKS proxy. + * + * Used to ensure raw TCP connections (e.g. ElectrumX for Namecoin) + * respect the user's Tor/proxy settings, preventing IP leaks. + */ +class ProxiedSocketFactory( + private val proxy: Proxy, +) : SocketFactory() { + override fun createSocket(): Socket = Socket(proxy) + + override fun createSocket( + host: String, + port: Int, + ): Socket = Socket(proxy).apply { connect(java.net.InetSocketAddress(host, port)) } + + override fun createSocket( + host: String, + port: Int, + localHost: InetAddress, + localPort: Int, + ): Socket = + Socket(proxy).apply { + bind(java.net.InetSocketAddress(localHost, localPort)) + connect(java.net.InetSocketAddress(host, port)) + } + + override fun createSocket( + host: InetAddress, + port: Int, + ): Socket = Socket(proxy).apply { connect(java.net.InetSocketAddress(host, port)) } + + override fun createSocket( + address: InetAddress, + port: Int, + localAddress: InetAddress, + localPort: Int, + ): Socket = + Socket(proxy).apply { + bind(java.net.InetSocketAddress(localAddress, localPort)) + connect(java.net.InetSocketAddress(address, port)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt index aacb3e262..daea244bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt @@ -25,6 +25,9 @@ import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.amethyst.ui.tor.TorType import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import okhttp3.OkHttpClient +import java.net.InetSocketAddress +import java.net.Proxy +import javax.net.SocketFactory class RoleBasedHttpClientBuilder( val okHttpClient: DualHttpClientManager, @@ -141,4 +144,24 @@ class RoleBasedHttpClientBuilder( override fun okHttpClientForPreview(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForPreviewUrl(url)) override fun okHttpClientForPushRegistration(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForTrustedRelays()) + + /** + * Returns a [SocketFactory] that routes through the user's Tor proxy + * when NIP-05 verification traffic should use Tor. + * + * Used by [ElectrumxClient] so that Namecoin lookups respect the + * same proxy/Tor settings as HTTP-based NIP-05 verification, + * preventing IP leaks through direct socket connections. + */ + fun socketFactoryForNip05(): SocketFactory { + // ElectrumX servers are always external, so we use a dummy + // non-localhost, non-onion URL to query the Tor policy. + val useTor = shouldUseTorForNIP05("https://electrumx.example.com") + if (!useTor) return SocketFactory.getDefault() + + val proxy = okHttpClient.getCurrentProxy() ?: return SocketFactory.getDefault() + val proxyAddr = proxy.address() as? InetSocketAddress ?: return SocketFactory.getDefault() + + return ProxiedSocketFactory(Proxy(Proxy.Type.SOCKS, proxyAddr)) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt index 178a755b6..6f58bfcaa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt @@ -38,10 +38,11 @@ import kotlinx.coroutines.launch * Thread-safe, lifecycle-aware, and designed for integration * into Amethyst's existing `ServiceManager` infrastructure. */ -class NamecoinNameService private constructor() { +class NamecoinNameService( + electrumxClient: ElectrumxClient = ElectrumxClient(), +) { private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) - private val electrumxClient = ElectrumxClient() private val resolver = NamecoinNameResolver(electrumxClient) private val cache = NamecoinLookupCache() @@ -53,8 +54,14 @@ class NamecoinNameService private constructor() { private var instance: NamecoinNameService? = null fun getInstance(): NamecoinNameService = - instance ?: synchronized(this) { - instance ?: NamecoinNameService().also { instance = it } + 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 } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/Nip05NamecoinAdapter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/Nip05NamecoinAdapter.kt deleted file mode 100644 index 706570c16..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/Nip05NamecoinAdapter.kt +++ /dev/null @@ -1,62 +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.nip05.namecoin.NamecoinNameResolver - -/** - * Static adapter for hooking Namecoin into NIP-05 verification. - */ -object Nip05NamecoinAdapter { - /** - * Attempt Namecoin-based NIP-05 verification. - * - * @param nip05Address The `nip05` field from a kind-0 event - * @param expectedPubkeyHex The hex pubkey from the same event - * @return `true` if verified via Namecoin, `false` if Namecoin says - * the mapping is wrong, or `null` if this identifier is not - * a Namecoin identifier (caller should fall through to HTTP NIP-05). - */ - suspend fun tryVerify( - nip05Address: String, - expectedPubkeyHex: String, - ): Boolean? { - if (!NamecoinNameResolver.isNamecoinIdentifier(nip05Address)) { - return null // Not a Namecoin identifier → let caller handle via HTTP - } - return NamecoinNameService.getInstance().verifyNip05(nip05Address, expectedPubkeyHex) - } - - /** - * Attempt to resolve a Namecoin identifier from the search bar. - * - * Called from the search/discovery flow when the user types an - * identifier. Returns the hex pubkey if found, null otherwise. - * - * @param query The user's search input - * @return Pair of (pubkey, relayList) or null - */ - suspend fun tryResolveSearch(query: String): Pair>? { - if (!NamecoinNameResolver.isNamecoinIdentifier(query)) return null - val result = NamecoinNameService.getInstance().resolve(query) ?: return null - return Pair(result.pubkey, result.relays) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/namecoin/NamecoinVerificationDisplay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/namecoin/NamecoinVerificationDisplay.kt deleted file mode 100644 index 2942b091f..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/namecoin/NamecoinVerificationDisplay.kt +++ /dev/null @@ -1,259 +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.ui.note.namecoin - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import com.vitorpamplona.amethyst.service.namecoin.NamecoinNameService -import com.vitorpamplona.amethyst.service.namecoin.NamecoinResolveState - -/** - * Display a Namecoin-verified identity badge. - * - * Shows the Namecoin name with a blockchain icon when verified. - * Renders nothing if verification fails or is still loading. - * - * @param nip05 The nip05 field value ending in .bit or starting with id/ - * @param expectedPubkeyHex The profile's pubkey to verify against - * @param modifier Standard compose modifier - */ -@Composable -fun NamecoinVerificationDisplay( - nip05: String, - expectedPubkeyHex: String, - modifier: Modifier = Modifier, -) { - var isVerified by remember(nip05, expectedPubkeyHex) { mutableStateOf(null) } - - LaunchedEffect(nip05, expectedPubkeyHex) { - isVerified = NamecoinNameService.getInstance().verifyNip05(nip05, expectedPubkeyHex) - } - - if (isVerified == true) { - NamecoinBadge( - displayName = formatDisplayName(nip05), - namespace = inferNamespace(nip05), - modifier = modifier, - ) - } -} - -/** - * The visual badge component. - * - * Shows: [chain icon] [display name] - * With namespace-appropriate styling. - */ -@Composable -private fun NamecoinBadge( - displayName: String, - namespace: String, // "d/" or "id/" - modifier: Modifier = Modifier, -) { - Row( - modifier = modifier.padding(top = 1.dp, bottom = 1.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.Start, - ) { - // Blockchain chain-link icon - // In production, replace with a proper vector drawable. - // For now, use a text glyph as placeholder. - Text( - text = "\u26D3", // ⛓ chain link emoji - fontSize = 12.sp, - color = NamecoinColors.chainIcon, - modifier = Modifier.padding(end = 2.dp), - ) - - // Namespace indicator - Text( - text = displayName, - fontSize = 14.sp, - color = NamecoinColors.verifiedText, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } -} - -/** - * Composable for the search result when a Namecoin name resolves. - * - * Shows the resolved Namecoin name, the Nostr pubkey, and relay hints. - * Used in the search results list. - */ -@Composable -fun NamecoinSearchResult( - identifier: String, - onProfileClick: (pubkeyHex: String) -> Unit, - modifier: Modifier = Modifier, -) { - val resolveState by NamecoinNameService - .getInstance() - .resolveLive(identifier) - .collectAsState() - - when (val state = resolveState) { - is NamecoinResolveState.Loading -> { - Row( - modifier = modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "\u26D3", // ⛓ - fontSize = 16.sp, - color = NamecoinColors.chainIcon, - ) - Spacer(Modifier.width(8.dp)) - Text( - text = "Resolving $identifier via Namecoin…", - fontSize = 14.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - is NamecoinResolveState.Resolved -> { - Row( - modifier = - modifier - .clickable { onProfileClick(state.result.pubkey) } - .padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "\u26D3", // ⛓ - fontSize = 16.sp, - color = NamecoinColors.verified, - ) - Spacer(Modifier.width(8.dp)) - // The resolved identity - Text( - text = formatDisplayName(identifier), - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - color = MaterialTheme.colorScheme.onSurface, - ) - Spacer(Modifier.width(4.dp)) - Text( - text = "(${state.result.pubkey.take(8)}…)", - fontSize = 12.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - is NamecoinResolveState.NotFound -> { - Row( - modifier = modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "No Nostr identity found for $identifier", - fontSize = 14.sp, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - is NamecoinResolveState.Error -> { - Row( - modifier = modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Namecoin lookup failed: ${state.message}", - fontSize = 14.sp, - color = MaterialTheme.colorScheme.error, - ) - } - } - } -} - -// ── Display Helpers ──────────────────────────────────────────────────── - -/** - * Format a raw identifier for user-friendly display. - * - * "alice@example.bit" → "alice@example.bit" - * "_@example.bit" → "example.bit" (NIP-05 root convention) - * "d/example" → "example.bit" - * "id/alice" → "id/alice" - */ -private fun formatDisplayName(identifier: String): String { - val input = identifier.trim() - - // _@domain.bit → show just domain.bit - if (input.startsWith("_@")) { - return input.removePrefix("_@") - } - - // d/name → name.bit - if (input.startsWith("d/", ignoreCase = true)) { - return input.removePrefix("d/").removePrefix("D/") + ".bit" - } - - // id/name stays as-is - if (input.startsWith("id/", ignoreCase = true)) { - return input - } - - return input -} - -private fun inferNamespace(identifier: String): String { - val input = identifier.trim().lowercase() - return when { - input.startsWith("id/") -> "id/" - else -> "d/" - } -} - -/** - * Color palette for Namecoin verification UI. - */ -private object NamecoinColors { - val chainIcon = Color(0xFF4A90D9) // Namecoin blue - val verified = Color(0xFF2E8B57) // Sea green — distinct from NIP-05 blue - val verifiedText = Color(0xFF4A90D9) // Namecoin blue for the text -} diff --git a/docs/namecoin-nip05-design.md b/docs/namecoin-nip05-design.md index 8289ac9d2..557e88ff9 100644 --- a/docs/namecoin-nip05-design.md +++ b/docs/namecoin-nip05-design.md @@ -17,43 +17,80 @@ This is censorship-resistant identity verification: no web server to seize, no D │ └── Namecoin path (new) ── NamecoinNameResolver │ │ │ │ │ NamecoinNameService (singleton) │ │ -│ ├── NamecoinLookupCache │ │ -│ └── Nip05NamecoinAdapter │ │ +│ └── NamecoinLookupCache │ │ │ │ │ -│ UI: NamecoinVerificationDisplay │ │ -│ NamecoinSearchResult │ │ +│ RoleBasedHttpClientBuilder │ │ +│ └── socketFactoryForNip05() ───┤ (Tor-aware sockets) │ +│ │ │ +│ ProxiedSocketFactory │ │ +│ └── SOCKS5 proxy routing ───┘ │ ├────────────────────────────────────┼────────────────────────┤ │ Quartz Library │ ├────────────────────────────────────┼────────────────────────┤ │ NamecoinNameResolver │ │ │ ├── parseIdentifier() │ │ │ ├── extractFromDomainValue() │ (d/ namespace) │ -│ └── extractFromIdentityValue() │ (id/ namespace) │ +│ ├── extractFromIdentityValue() │ (id/ namespace) │ +│ └── serverListProvider() │ (Tor/clearnet routing) │ │ │ │ │ ElectrumxClient │ │ │ ├── buildNameIndexScript() │ │ │ ├── electrumScriptHash() │ │ -│ └── parseNameScript() │ │ +│ ├── parseNameScript() │ │ +│ └── socketFactory() ───┤ (injected, proxy-aware)│ │ ▼ │ -│ ┌──────────────────┐ │ -│ │ ElectrumX Server │ │ -│ │ (Namecoin node) │ │ -│ └──────────────────┘ │ +│ ┌───────────────────────────┐ │ +│ │ ElectrumX Server │ │ +│ │ (clearnet or .onion) │ │ +│ └───────────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ ``` ### Layer Separation - **`quartz/` (library)** — Protocol-level logic. No Android dependencies. - - `ElectrumxClient` — TCP/TLS connection to ElectrumX, JSON-RPC, script parsing - - `NamecoinNameResolver` — Identifier parsing, value extraction, NIP-05 mapping + - `ElectrumxClient` — TCP/TLS connection to ElectrumX, JSON-RPC, script parsing. Accepts an injected `SocketFactory` lambda for proxy/Tor support. + - `NamecoinNameResolver` — Identifier parsing, value extraction, NIP-05 mapping. Accepts a `serverListProvider` lambda for dynamic server selection. - `NamecoinLookupCache` — LRU cache with TTL - `NamecoinNameResolverTest` — Unit tests for parsing and value extraction -- **`amethyst/` (app)** — Android integration and UI. - - `NamecoinNameService` — Application singleton, lifecycle management - - `Nip05NamecoinAdapter` — Static bridge for NIP-05 verification hooks - - `NamecoinVerificationDisplay` — Compose UI for verified badge + search results +- **`amethyst/` (app)** — Android integration and Tor-aware wiring. + - `NamecoinNameService` — Application singleton, initialized with a proxy-aware `ElectrumxClient` + - `ProxiedSocketFactory` — `SocketFactory` implementation that routes through a SOCKS5 proxy (Tor) + - `RoleBasedHttpClientBuilder.socketFactoryForNip05()` — Returns a proxy-aware or default `SocketFactory` based on current Tor settings + +## Tor & Proxy Integration + +The ElectrumX connection respects the user's Tor settings to prevent IP leaks: + +### Problem + +The original `ElectrumxClient` used raw `java.net.Socket` / `SSLSocket` directly, bypassing OkHttp entirely. This meant Namecoin lookups would leak the user's real IP even when they had configured Tor for NIP-05 verification traffic. + +### Solution + +1. **`ElectrumxClient`** accepts a `socketFactory: () -> SocketFactory` lambda (evaluated at each connection, not captured at construction) +2. **`ProxiedSocketFactory`** creates sockets routed through a `java.net.Proxy` (SOCKS5) +3. **`RoleBasedHttpClientBuilder.socketFactoryForNip05()`** checks the user's NIP-05 Tor settings and returns either `SocketFactory.getDefault()` or a `ProxiedSocketFactory` with the active Tor SOCKS proxy +4. SSL is layered on top of the (possibly proxied) base socket via `SSLSocketFactory.createSocket(socket, host, port, autoClose)`, preserving the proxy tunnel + +### Server Selection + +When Tor is enabled for NIP-05 traffic, the server list switches to prioritize onion routing: + +| Setting | Primary server | Fallback | +|---|---|---| +| **Tor off** | `electrumx.testls.space:50002` | `ulrichard.ch:50006`, `nmc2.lelux.fi:50006` | +| **Tor on** | `.onion:50002` (see below) | `electrumx.testls.space:50002` (via Tor) | + +The `serverListProvider` lambda in `NamecoinNameResolver` is evaluated at resolution time, so toggling Tor settings takes effect immediately without restarting the app. + +### Dynamic Evaluation + +Both the socket factory and server list are provided as lambdas, not captured values. This means: +- Toggling Tor on/off in settings takes effect on the next Namecoin lookup +- The proxy port is read from `DualHttpClientManager`'s live `StateFlow` +- No app restart or singleton reconstruction needed ## ElectrumX Protocol — How Name Resolution Works @@ -152,7 +189,7 @@ The integration is minimal and non-invasive: 1. **`Nip05Client`** gains an optional `namecoinResolver` parameter 2. On `verify()` and `get()`, if the identifier matches `.bit` / `d/` / `id/`, it routes to `NamecoinNameResolver` instead of the HTTP fetcher 3. Non-Namecoin identifiers are completely unaffected -4. **`AppModules`** wires up the resolver at construction time +4. **`AppModules`** wires up the resolver with Tor-aware socket factory and dynamic server selection ## Search Integration @@ -165,15 +202,20 @@ The search bar resolves Namecoin identifiers in real-time via `SearchBarViewMode This means typing `alice@example.bit`, `example.bit`, `d/example`, or `id/alice` into the search bar will query the Namecoin blockchain and show the resolved user profile at the top of results. -## Default ElectrumX Server +## Default ElectrumX Servers -``` -electrumx.testls.space:50002 (TLS, self-signed certificate) -``` +### Clearnet (Tor off) +| Server | Port | TLS | Notes | +|---|---|---|---| +| `electrumx.testls.space` | 50002 | Yes (self-signed) | Primary | +| `ulrichard.ch` | 50006 | Yes | Fallback | +| `nmc2.lelux.fi` | 50006 | Yes | Fallback | -- ElectrumX 1.16.0, Namecoin chain, protocol 1.4–1.4.3 -- Also available via Tor: `i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion:50002` -- Fallback servers: `ulrichard.ch:50006`, `nmc2.lelux.fi:50006` (currently offline) +### Tor (Tor on for NIP-05) +| Server | Port | TLS | Notes | +|---|---|---|---| +| `i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion` | 50002 | Yes (self-signed) | Primary — onion service for `electrumx.testls.space` | +| `electrumx.testls.space` | 50002 | Yes (self-signed) | Fallback (routed through Tor SOCKS proxy) | The `trustAllCerts` flag is set for servers with self-signed certificates. Users can configure custom servers via `NamecoinNameService.setCustomServers()`. @@ -184,38 +226,31 @@ The `trustAllCerts` flag is set for servers with self-signed certificates. Users - Both positive and negative results are cached - Cache is invalidated on TTL expiry; manual `invalidate()` and `clear()` are available -## UI Components - -### NamecoinVerificationDisplay -Shows a ⛓ chain-link badge next to profiles verified via Namecoin. Distinct from the standard NIP-05 checkmark — uses Namecoin blue (#4A90D9) and sea green (#2E8B57). - -### NamecoinSearchResult -Search bar integration. When a user types a `.bit` identifier, shows a loading state during resolution, then the resolved pubkey with a clickable profile link. - ## Security Considerations +- **Tor integration**: ElectrumX connections are routed through the user's Tor SOCKS proxy when NIP-05 Tor settings are enabled. This prevents IP leaks to ElectrumX servers. The onion server is preferred when Tor is active, providing end-to-end onion routing. - **Self-signed certificates**: The primary ElectrumX server uses a self-signed TLS cert. The `trustAllCerts` option accepts any certificate for that server. This is acceptable because the Namecoin blockchain itself provides the trust anchor — we verify names against on-chain data, not the transport layer. A MITM could return stale data but cannot forge name registrations. - **Name expiry**: Namecoin names expire after ~36,000 blocks (~250 days) if not renewed. The current implementation does not check expiry. Future work should compare the name's `height` + `expiresIn` against the current block height. - **Server trust**: The client trusts that the ElectrumX server returns accurate transaction data. For higher assurance, SPV proof verification could be added in the future. +- **Dynamic proxy evaluation**: Socket factory and server list are evaluated per-request (via lambdas), ensuring Tor setting changes take effect immediately without stale socket reuse. ## Files Changed ### New files (quartz/) -- `quartz/.../nip05/namecoin/ElectrumxClient.kt` — ElectrumX TCP/TLS client, scripthash-based name resolution -- `quartz/.../nip05/namecoin/NamecoinNameResolver.kt` — Identifier parsing, value extraction +- `quartz/.../nip05/namecoin/ElectrumxClient.kt` — ElectrumX TCP/TLS client with injected `SocketFactory` for proxy support +- `quartz/.../nip05/namecoin/NamecoinNameResolver.kt` — Identifier parsing, value extraction, dynamic server selection - `quartz/.../nip05/namecoin/NamecoinLookupCache.kt` — LRU cache with TTL - `quartz/src/jvmTest/.../NamecoinNameResolverTest.kt` — Unit tests ### New files (amethyst/) -- `amethyst/.../service/namecoin/NamecoinNameService.kt` — App singleton, coroutine scope -- `amethyst/.../service/namecoin/Nip05NamecoinAdapter.kt` — Static bridge for NIP-05 hooks -- `amethyst/.../ui/note/namecoin/NamecoinVerificationDisplay.kt` — Compose UI components +- `amethyst/.../service/namecoin/NamecoinNameService.kt` — App singleton, initialized with proxy-aware ElectrumxClient +- `amethyst/.../model/privacyOptions/ProxiedSocketFactory.kt` — `SocketFactory` that routes through SOCKS5 proxy (Tor) ### Modified files -- `amethyst/.../AppModules.kt` — Wire up `NamecoinNameResolver` into `Nip05Client` +- `amethyst/.../AppModules.kt` — Wires up resolver with Tor-aware socket factory and server list provider +- `amethyst/.../model/privacyOptions/RoleBasedHttpClientBuilder.kt` — Added `socketFactoryForNip05()` for proxy-aware socket creation - `amethyst/.../ui/screen/loggedIn/search/SearchBarViewModel.kt` — Namecoin search resolution - `quartz/.../nip05DnsIdentifiers/Nip05Client.kt` — Route `.bit` identifiers to Namecoin resolver -- `amethyst/.../relays/RelayInformationScreen.kt` — Import reordering (spotless) ## Testing @@ -241,15 +276,20 @@ adb install -r amethyst/build/outputs/apk/play/debug/amethyst-play-universal-deb | `d/testls` | Resolves to Vitor Pamplona's profile | Direct `d/` namespace | | `id/someuser` | Resolves if registered on-chain | Direct `id/` namespace | +**Tor tests** — enable Tor and set "NIP-05 verifications via Tor" to on: +1. Search for `m@testls.bit` — should resolve via onion server +2. Verify no direct clearnet connections to ElectrumX servers (use `tcpdump`) +3. Toggle Tor off — next search should use clearnet servers + **Verification test** — if a profile has a `.bit` address in its `nip05` field, the NIP-05 badge should verify via the blockchain instead of HTTP. **Network verification** — to confirm ElectrumX calls are being made: ```bash -# Monitor traffic to ElectrumX ports on the emulator adb root adb shell tcpdump -i any -nn port 50002 or port 50006 ``` -You should see TCP connections to `162.212.154.52:50002` (electrumx.testls.space) when searching for `.bit` identifiers. +With Tor off, you should see TCP connections to `162.212.154.52:50002` (electrumx.testls.space). +With Tor on, you should see connections to the local Tor SOCKS port only (no direct ElectrumX connections). ### Live test data The name `d/testls` is registered on the Namecoin blockchain (block 551519+, last updated block 814278) with value: diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt index 346e7bb8b..4f61a1fb0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt @@ -42,6 +42,7 @@ import java.net.InetSocketAddress import java.net.Socket import java.security.MessageDigest import java.util.concurrent.atomic.AtomicInteger +import javax.net.SocketFactory import javax.net.ssl.SSLContext import javax.net.ssl.SSLSocketFactory import javax.net.ssl.TrustManager @@ -97,6 +98,7 @@ data class ElectrumxServer( class ElectrumxClient( private val connectTimeoutMs: Long = 10_000L, private val readTimeoutMs: Long = 15_000L, + private val socketFactory: () -> SocketFactory = { SocketFactory.getDefault() }, ) { private val json = Json { @@ -107,7 +109,7 @@ class ElectrumxClient( private val mutex = Mutex() companion object { - /** Well-known public Namecoin ElectrumX servers. */ + /** Well-known public Namecoin ElectrumX servers (clearnet). */ val DEFAULT_SERVERS = listOf( ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true), @@ -115,6 +117,18 @@ class ElectrumxClient( ElectrumxServer("nmc2.lelux.fi", 50006, useSsl = 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), + ) + private const val PROTOCOL_VERSION = "1.4" // Namecoin script opcodes @@ -155,10 +169,16 @@ class ElectrumxClient( } /** - * Try each default server in order until one succeeds. + * 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] */ - suspend fun nameShowWithFallback(identifier: String): NameShowResult? { - for (server in DEFAULT_SERVERS) { + suspend fun nameShowWithFallback( + identifier: String, + servers: List = DEFAULT_SERVERS, + ): NameShowResult? { + for (server in servers) { val result = nameShow(identifier, server) if (result != null) return result } @@ -412,22 +432,25 @@ class ElectrumxClient( return data } - private fun createSocket(server: ElectrumxServer): Socket = - if (server.useSsl) { - val factory = - if (server.trustAllCerts) { - trustAllSslFactory() - } else { - SSLSocketFactory.getDefault() as SSLSocketFactory - } - factory.createSocket().apply { + private fun createSocket(server: ElectrumxServer): Socket { + // Create the base socket through the injected factory, which + // may route through a SOCKS proxy (e.g. Tor) if configured. + val baseSocket = + socketFactory().createSocket().apply { connect(InetSocketAddress(server.host, server.port), connectTimeoutMs.toInt()) } - } else { - Socket().apply { - connect(InetSocketAddress(server.host, server.port), connectTimeoutMs.toInt()) + + if (!server.useSsl) return baseSocket + + // Upgrade to TLS over the already-connected (possibly proxied) socket. + val sslFactory = + if (server.trustAllCerts) { + trustAllSslFactory() + } else { + SSLSocketFactory.getDefault() as SSLSocketFactory } - } + return sslFactory.createSocket(baseSocket, server.host, server.port, true) + } /** * Create an SSLSocketFactory that accepts any certificate. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolver.kt index 7f1edbf92..f0f7549c1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolver.kt @@ -52,6 +52,7 @@ data class NamecoinNostrResult( class NamecoinNameResolver( private val electrumxClient: ElectrumxClient = ElectrumxClient(), private val lookupTimeoutMs: Long = 20_000L, + private val serverListProvider: () -> List = { ElectrumxClient.DEFAULT_SERVERS }, ) { private val json = Json { @@ -165,7 +166,7 @@ class NamecoinNameResolver( // ── Lookup & Value Parsing ───────────────────────────────────────── private suspend fun performLookup(parsed: ParsedIdentifier): NamecoinNostrResult? { - val nameResult = electrumxClient.nameShowWithFallback(parsed.namecoinName) ?: return null + val nameResult = electrumxClient.nameShowWithFallback(parsed.namecoinName, serverListProvider()) ?: return null val valueJson = tryParseJson(nameResult.value) ?: return null return when (parsed.namespace) { From 69e95159e9e71e282b93d23580b9eb2752bc16ce Mon Sep 17 00:00:00 2001 From: M Date: Tue, 3 Mar 2026 06:56:59 +1100 Subject: [PATCH 4/6] fix: check Namecoin name expiry before resolving Query current block height via blockchain.headers.subscribe and reject names that have expired (>= 36000 blocks since last update). Populate expiresIn field in NameShowResult for downstream use. Unconfirmed transactions (height <= 0) are treated as active. --- .../quartz/nip05/namecoin/ElectrumxClient.kt | 47 ++++++++++++++++++- 1 file changed, 45 insertions(+), 2 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt index 4f61a1fb0..2e3cf2e1a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt @@ -131,6 +131,13 @@ class ElectrumxClient( private const val PROTOCOL_VERSION = "1.4" + /** + * Namecoin names expire this many blocks after their last update. + * From chainparams.cpp: consensus.nNameExpirationDepth = 36000 + * (~250 days at ~10 min/block). + */ + const val NAME_EXPIRE_DEPTH = 36_000 + // Namecoin script opcodes private const val OP_NAME_UPDATE: Byte = 0x53 // OP_3 repurposed by Namecoin private const val OP_2DROP: Byte = 0x6d @@ -222,8 +229,28 @@ class ElectrumxClient( writer.println(txReq) val txResponse = reader.readLine() ?: return null - // 5. Parse the name value from the transaction - return parseNameFromTransaction(identifier, txHash, height, txResponse) + // 5. Get current block height to check name expiry + val headersReq = buildRpcRequest("blockchain.headers.subscribe", emptyList()) + writer.println(headersReq) + val headersResponse = reader.readLine() + val currentHeight = parseBlockHeight(headersResponse) + + // 6. Check if the name has expired + if (currentHeight != null && height > 0) { + val blocksSinceUpdate = currentHeight - height + if (blocksSinceUpdate >= NAME_EXPIRE_DEPTH) { + return null // Name has expired + } + } + + // 7. Parse the name value from the transaction + val result = parseNameFromTransaction(identifier, txHash, height, txResponse) + // Populate expiresIn if we know the current height + return if (result != null && currentHeight != null && height > 0) { + result.copy(expiresIn = NAME_EXPIRE_DEPTH - (currentHeight - height)) + } else { + result + } } finally { runCatching { writer.close() } runCatching { reader.close() } @@ -279,6 +306,22 @@ class ElectrumxClient( return digest.reversedArray().joinToString("") { "%02x".format(it) } } + /** + * Parse the block height from a `blockchain.headers.subscribe` response. + * + * Response format: {"result": {"height": 814300, "hex": "..."}, ...} + */ + private fun parseBlockHeight(raw: String?): Int? { + if (raw == null) return null + return try { + val envelope = json.parseToJsonElement(raw).jsonObject + val result = envelope["result"]?.jsonObject ?: return null + result["height"]?.jsonPrimitive?.int + } catch (_: Exception) { + null + } + } + /** * Parse the history response into a list of (txHash, height) pairs. */ From c649f2163b309f24ef59b4e464e2c46c9a77b8db Mon Sep 17 00:00:00 2001 From: M Date: Wed, 4 Mar 2026 06:30:50 +1100 Subject: [PATCH 5/6] Add nmc2.bitcoins.sk and 46.229.238.187 as backup ElectrumX servers Both verified to resolve Namecoin names via scripthash lookups. Port 57002, TLS with self-signed certs. --- .../com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt index 2e3cf2e1a..efbba9349 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt @@ -115,6 +115,8 @@ class ElectrumxClient( ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true), ElectrumxServer("ulrichard.ch", 50006, useSsl = true), ElectrumxServer("nmc2.lelux.fi", 50006, useSsl = 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. */ @@ -127,6 +129,7 @@ class ElectrumxClient( 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" From cb1031705bdba1806018e5dbec83a4a1728bfe7c Mon Sep 17 00:00:00 2001 From: M Date: Wed, 4 Mar 2026 06:34:39 +1100 Subject: [PATCH 6/6] Remove dead ElectrumX servers ulrichard.ch and nmc2.lelux.fi MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ulrichard.ch:50006 — connects but never responds (timeout) nmc2.lelux.fi:50006 — DNS resolution fails (ENOTFOUND) --- .../com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt index efbba9349..6e63b785e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt @@ -113,8 +113,6 @@ class ElectrumxClient( val DEFAULT_SERVERS = listOf( ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true), - ElectrumxServer("ulrichard.ch", 50006, useSsl = true), - ElectrumxServer("nmc2.lelux.fi", 50006, useSsl = true), ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true), ElectrumxServer("46.229.238.187", 57002, useSsl = true, trustAllCerts = true), )