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.
This commit is contained in:
@@ -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() }
|
||||
|
||||
+158
@@ -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<ElectrumxServer> = 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<NamecoinResolveState> {
|
||||
val state = MutableStateFlow<NamecoinResolveState>(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<ElectrumxServer>) {
|
||||
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()
|
||||
}
|
||||
+62
@@ -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<String, List<String>>? {
|
||||
if (!NamecoinNameResolver.isNamecoinIdentifier(query)) return null
|
||||
val result = NamecoinNameService.getInstance().resolve(query) ?: return null
|
||||
return Pair(result.pubkey, result.relays)
|
||||
}
|
||||
}
|
||||
+259
@@ -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<Boolean?>(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
|
||||
}
|
||||
+17
-8
@@ -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),
|
||||
|
||||
@@ -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 <push(name)> <push(value_json)> OP_2DROP OP_DROP <address_script>
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
+487
@@ -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 <push(name)> <push(empty)> 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<Byte>()
|
||||
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<Pair<String, Int>>? {
|
||||
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 <push(name)> <push(value)> OP_2DROP OP_DROP <address_script>
|
||||
*
|
||||
* @return Pair of (name, value) as strings, or null if parsing fails
|
||||
*/
|
||||
private fun parseNameScript(script: ByteArray): Pair<String, String>? {
|
||||
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<ByteArray, Int>? {
|
||||
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<TrustManager>(
|
||||
object : X509TrustManager {
|
||||
override fun checkClientTrusted(
|
||||
chain: Array<java.security.cert.X509Certificate>,
|
||||
authType: String,
|
||||
) {}
|
||||
|
||||
override fun checkServerTrusted(
|
||||
chain: Array<java.security.cert.X509Certificate>,
|
||||
authType: String,
|
||||
) {}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
|
||||
},
|
||||
)
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(null, trustAllCerts, java.security.SecureRandom())
|
||||
return sslContext.socketFactory
|
||||
}
|
||||
|
||||
private fun buildRpcRequest(
|
||||
method: String,
|
||||
params: List<Any>,
|
||||
): 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)
|
||||
}
|
||||
}
|
||||
+77
@@ -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<String, CachedResult>(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()
|
||||
}
|
||||
}
|
||||
+313
@@ -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<String> = 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<String> {
|
||||
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)
|
||||
}
|
||||
+17
-1
@@ -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))
|
||||
|
||||
|
||||
+281
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user