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 +}