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:
M
2026-03-02 13:07:46 +11:00
parent cd18fdf79a
commit d82ace2f63
11 changed files with 1908 additions and 10 deletions
@@ -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() }
@@ -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()
}
@@ -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)
}
}
@@ -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
}
@@ -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),
+231
View File
@@ -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.41.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"
}
}
}
```
@@ -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)
}
}
@@ -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()
}
}
@@ -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)
}
@@ -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))
@@ -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
}