Merge pull request #1734 from mstrofnone/feature/namecoin-nip05-resolution
feat: Namecoin NIP-05 identity verification via ElectrumX
This commit is contained in:
@@ -142,7 +142,24 @@ class AppModules(
|
||||
|
||||
// Custom fetcher that considers tor settings and avoids forwarding.
|
||||
val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05)
|
||||
val nip05Client = Nip05Client(nip05Fetcher)
|
||||
val namecoinElectrumxClient =
|
||||
com.vitorpamplona.quartz.nip05.namecoin.ElectrumxClient(
|
||||
socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() },
|
||||
)
|
||||
val namecoinResolver =
|
||||
com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver(
|
||||
electrumxClient = namecoinElectrumxClient,
|
||||
serverListProvider = {
|
||||
if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) {
|
||||
com.vitorpamplona.quartz.nip05.namecoin.ElectrumxClient.TOR_SERVERS
|
||||
} else {
|
||||
com.vitorpamplona.quartz.nip05.namecoin.ElectrumxClient.DEFAULT_SERVERS
|
||||
}
|
||||
},
|
||||
)
|
||||
val namecoinNameService =
|
||||
com.vitorpamplona.amethyst.service.namecoin.NamecoinNameService.init(namecoinElectrumxClient)
|
||||
val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver)
|
||||
|
||||
// Application-wide block height request cache
|
||||
val otsBlockHeightCache by lazy { OtsBlockHeightCache() }
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.privacyOptions
|
||||
|
||||
import java.net.InetAddress
|
||||
import java.net.Proxy
|
||||
import java.net.Socket
|
||||
import javax.net.SocketFactory
|
||||
|
||||
/**
|
||||
* A [SocketFactory] that creates sockets routed through a SOCKS proxy.
|
||||
*
|
||||
* Used to ensure raw TCP connections (e.g. ElectrumX for Namecoin)
|
||||
* respect the user's Tor/proxy settings, preventing IP leaks.
|
||||
*/
|
||||
class ProxiedSocketFactory(
|
||||
private val proxy: Proxy,
|
||||
) : SocketFactory() {
|
||||
override fun createSocket(): Socket = Socket(proxy)
|
||||
|
||||
override fun createSocket(
|
||||
host: String,
|
||||
port: Int,
|
||||
): Socket = Socket(proxy).apply { connect(java.net.InetSocketAddress(host, port)) }
|
||||
|
||||
override fun createSocket(
|
||||
host: String,
|
||||
port: Int,
|
||||
localHost: InetAddress,
|
||||
localPort: Int,
|
||||
): Socket =
|
||||
Socket(proxy).apply {
|
||||
bind(java.net.InetSocketAddress(localHost, localPort))
|
||||
connect(java.net.InetSocketAddress(host, port))
|
||||
}
|
||||
|
||||
override fun createSocket(
|
||||
host: InetAddress,
|
||||
port: Int,
|
||||
): Socket = Socket(proxy).apply { connect(java.net.InetSocketAddress(host, port)) }
|
||||
|
||||
override fun createSocket(
|
||||
address: InetAddress,
|
||||
port: Int,
|
||||
localAddress: InetAddress,
|
||||
localPort: Int,
|
||||
): Socket =
|
||||
Socket(proxy).apply {
|
||||
bind(java.net.InetSocketAddress(localAddress, localPort))
|
||||
connect(java.net.InetSocketAddress(address, port))
|
||||
}
|
||||
}
|
||||
+23
@@ -25,6 +25,9 @@ import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow
|
||||
import com.vitorpamplona.amethyst.ui.tor.TorType
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Proxy
|
||||
import javax.net.SocketFactory
|
||||
|
||||
class RoleBasedHttpClientBuilder(
|
||||
val okHttpClient: DualHttpClientManager,
|
||||
@@ -141,4 +144,24 @@ class RoleBasedHttpClientBuilder(
|
||||
override fun okHttpClientForPreview(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForPreviewUrl(url))
|
||||
|
||||
override fun okHttpClientForPushRegistration(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForTrustedRelays())
|
||||
|
||||
/**
|
||||
* Returns a [SocketFactory] that routes through the user's Tor proxy
|
||||
* when NIP-05 verification traffic should use Tor.
|
||||
*
|
||||
* Used by [ElectrumxClient] so that Namecoin lookups respect the
|
||||
* same proxy/Tor settings as HTTP-based NIP-05 verification,
|
||||
* preventing IP leaks through direct socket connections.
|
||||
*/
|
||||
fun socketFactoryForNip05(): SocketFactory {
|
||||
// ElectrumX servers are always external, so we use a dummy
|
||||
// non-localhost, non-onion URL to query the Tor policy.
|
||||
val useTor = shouldUseTorForNIP05("https://electrumx.example.com")
|
||||
if (!useTor) return SocketFactory.getDefault()
|
||||
|
||||
val proxy = okHttpClient.getCurrentProxy() ?: return SocketFactory.getDefault()
|
||||
val proxyAddr = proxy.address() as? InetSocketAddress ?: return SocketFactory.getDefault()
|
||||
|
||||
return ProxiedSocketFactory(Proxy(Proxy.Type.SOCKS, proxyAddr))
|
||||
}
|
||||
}
|
||||
|
||||
+165
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* 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(
|
||||
electrumxClient: ElectrumxClient = ElectrumxClient(),
|
||||
) {
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
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 ?: throw IllegalStateException(
|
||||
"NamecoinNameService not initialized. Call init() first.",
|
||||
)
|
||||
|
||||
fun init(electrumxClient: ElectrumxClient): NamecoinNameService =
|
||||
synchronized(this) {
|
||||
instance?.let { return it }
|
||||
NamecoinNameService(electrumxClient).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()
|
||||
}
|
||||
+36
-2
@@ -32,8 +32,11 @@ import androidx.lifecycle.viewModelScope
|
||||
import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.model.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.User
|
||||
import com.vitorpamplona.amethyst.service.namecoin.NamecoinNameService
|
||||
import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState
|
||||
import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder
|
||||
import com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver
|
||||
import com.vitorpamplona.quartz.nip10Notes.content.findHashtags
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
@@ -43,7 +46,9 @@ import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.flow.update
|
||||
@@ -69,12 +74,41 @@ class SearchBarViewModel(
|
||||
|
||||
val searchDataSourceState = SearchQueryState(MutableStateFlow(searchValue), account)
|
||||
|
||||
/**
|
||||
* Resolves Namecoin identifiers (.bit / d/ / id/) via ElectrumX and
|
||||
* returns the matching [User] from LocalCache, or null.
|
||||
*/
|
||||
private val namecoinResolvedUser =
|
||||
searchValueFlow
|
||||
.debounce(400)
|
||||
.distinctUntilChanged()
|
||||
.filter { NamecoinNameResolver.isNamecoinIdentifier(it) }
|
||||
.map { term ->
|
||||
try {
|
||||
val result = NamecoinNameService.getInstance().resolve(term)
|
||||
if (result != null) {
|
||||
LocalCache.getOrCreateUser(result.pubkey)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(viewModelScope, WhileSubscribed(5000), null)
|
||||
|
||||
val searchResultsUsers =
|
||||
combine(
|
||||
searchValueFlow.debounce(100),
|
||||
invalidations.debounce(100),
|
||||
) { term, version ->
|
||||
LocalCache.findUsersStartingWith(term, account)
|
||||
namecoinResolvedUser,
|
||||
) { term, version, namecoinUser ->
|
||||
val localResults = LocalCache.findUsersStartingWith(term, account)
|
||||
if (namecoinUser != null && localResults.none { it.pubkeyHex == namecoinUser.pubkeyHex }) {
|
||||
listOf(namecoinUser) + localResults
|
||||
} else {
|
||||
localResults
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
.stateIn(viewModelScope, WhileSubscribed(5000), emptyList())
|
||||
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
# 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 │ │
|
||||
│ │ │
|
||||
│ RoleBasedHttpClientBuilder │ │
|
||||
│ └── socketFactoryForNip05() ───┤ (Tor-aware sockets) │
|
||||
│ │ │
|
||||
│ ProxiedSocketFactory │ │
|
||||
│ └── SOCKS5 proxy routing ───┘ │
|
||||
├────────────────────────────────────┼────────────────────────┤
|
||||
│ Quartz Library │
|
||||
├────────────────────────────────────┼────────────────────────┤
|
||||
│ NamecoinNameResolver │ │
|
||||
│ ├── parseIdentifier() │ │
|
||||
│ ├── extractFromDomainValue() │ (d/ namespace) │
|
||||
│ ├── extractFromIdentityValue() │ (id/ namespace) │
|
||||
│ └── serverListProvider() │ (Tor/clearnet routing) │
|
||||
│ │ │
|
||||
│ ElectrumxClient │ │
|
||||
│ ├── buildNameIndexScript() │ │
|
||||
│ ├── electrumScriptHash() │ │
|
||||
│ ├── parseNameScript() │ │
|
||||
│ └── socketFactory() ───┤ (injected, proxy-aware)│
|
||||
│ ▼ │
|
||||
│ ┌───────────────────────────┐ │
|
||||
│ │ ElectrumX Server │ │
|
||||
│ │ (clearnet or .onion) │ │
|
||||
│ └───────────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Layer Separation
|
||||
|
||||
- **`quartz/` (library)** — Protocol-level logic. No Android dependencies.
|
||||
- `ElectrumxClient` — TCP/TLS connection to ElectrumX, JSON-RPC, script parsing. Accepts an injected `SocketFactory` lambda for proxy/Tor support.
|
||||
- `NamecoinNameResolver` — Identifier parsing, value extraction, NIP-05 mapping. Accepts a `serverListProvider` lambda for dynamic server selection.
|
||||
- `NamecoinLookupCache` — LRU cache with TTL
|
||||
- `NamecoinNameResolverTest` — Unit tests for parsing and value extraction
|
||||
|
||||
- **`amethyst/` (app)** — Android integration and Tor-aware wiring.
|
||||
- `NamecoinNameService` — Application singleton, initialized with a proxy-aware `ElectrumxClient`
|
||||
- `ProxiedSocketFactory` — `SocketFactory` implementation that routes through a SOCKS5 proxy (Tor)
|
||||
- `RoleBasedHttpClientBuilder.socketFactoryForNip05()` — Returns a proxy-aware or default `SocketFactory` based on current Tor settings
|
||||
|
||||
## Tor & Proxy Integration
|
||||
|
||||
The ElectrumX connection respects the user's Tor settings to prevent IP leaks:
|
||||
|
||||
### Problem
|
||||
|
||||
The original `ElectrumxClient` used raw `java.net.Socket` / `SSLSocket` directly, bypassing OkHttp entirely. This meant Namecoin lookups would leak the user's real IP even when they had configured Tor for NIP-05 verification traffic.
|
||||
|
||||
### Solution
|
||||
|
||||
1. **`ElectrumxClient`** accepts a `socketFactory: () -> SocketFactory` lambda (evaluated at each connection, not captured at construction)
|
||||
2. **`ProxiedSocketFactory`** creates sockets routed through a `java.net.Proxy` (SOCKS5)
|
||||
3. **`RoleBasedHttpClientBuilder.socketFactoryForNip05()`** checks the user's NIP-05 Tor settings and returns either `SocketFactory.getDefault()` or a `ProxiedSocketFactory` with the active Tor SOCKS proxy
|
||||
4. SSL is layered on top of the (possibly proxied) base socket via `SSLSocketFactory.createSocket(socket, host, port, autoClose)`, preserving the proxy tunnel
|
||||
|
||||
### Server Selection
|
||||
|
||||
When Tor is enabled for NIP-05 traffic, the server list switches to prioritize onion routing:
|
||||
|
||||
| Setting | Primary server | Fallback |
|
||||
|---|---|---|
|
||||
| **Tor off** | `electrumx.testls.space:50002` | `ulrichard.ch:50006`, `nmc2.lelux.fi:50006` |
|
||||
| **Tor on** | `.onion:50002` (see below) | `electrumx.testls.space:50002` (via Tor) |
|
||||
|
||||
The `serverListProvider` lambda in `NamecoinNameResolver` is evaluated at resolution time, so toggling Tor settings takes effect immediately without restarting the app.
|
||||
|
||||
### Dynamic Evaluation
|
||||
|
||||
Both the socket factory and server list are provided as lambdas, not captured values. This means:
|
||||
- Toggling Tor on/off in settings takes effect on the next Namecoin lookup
|
||||
- The proxy port is read from `DualHttpClientManager`'s live `StateFlow`
|
||||
- No app restart or singleton reconstruction needed
|
||||
|
||||
## ElectrumX Protocol — How Name Resolution Works
|
||||
|
||||
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 with Tor-aware socket factory and dynamic server selection
|
||||
|
||||
## Search Integration
|
||||
|
||||
The search bar resolves Namecoin identifiers in real-time via `SearchBarViewModel`:
|
||||
|
||||
1. A `namecoinResolvedUser` flow watches the search input with a 400ms debounce
|
||||
2. If the input matches any Namecoin format (`d/*`, `id/*`, `*.bit`, `*@*.bit`), it resolves via `NamecoinNameService` → `ElectrumxClient` → blockchain
|
||||
3. The resolved pubkey is used to get/create a `User` in `LocalCache`
|
||||
4. The Namecoin-resolved user is prepended to the standard local search results (deduplicated)
|
||||
|
||||
This means typing `alice@example.bit`, `example.bit`, `d/example`, or `id/alice` into the search bar will query the Namecoin blockchain and show the resolved user profile at the top of results.
|
||||
|
||||
## Default ElectrumX Servers
|
||||
|
||||
### Clearnet (Tor off)
|
||||
| Server | Port | TLS | Notes |
|
||||
|---|---|---|---|
|
||||
| `electrumx.testls.space` | 50002 | Yes (self-signed) | Primary |
|
||||
| `ulrichard.ch` | 50006 | Yes | Fallback |
|
||||
| `nmc2.lelux.fi` | 50006 | Yes | Fallback |
|
||||
|
||||
### Tor (Tor on for NIP-05)
|
||||
| Server | Port | TLS | Notes |
|
||||
|---|---|---|---|
|
||||
| `i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion` | 50002 | Yes (self-signed) | Primary — onion service for `electrumx.testls.space` |
|
||||
| `electrumx.testls.space` | 50002 | Yes (self-signed) | Fallback (routed through Tor SOCKS proxy) |
|
||||
|
||||
The `trustAllCerts` flag is set for servers with self-signed certificates. Users can configure custom servers via `NamecoinNameService.setCustomServers()`.
|
||||
|
||||
## 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
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- **Tor integration**: ElectrumX connections are routed through the user's Tor SOCKS proxy when NIP-05 Tor settings are enabled. This prevents IP leaks to ElectrumX servers. The onion server is preferred when Tor is active, providing end-to-end onion routing.
|
||||
- **Self-signed certificates**: The primary ElectrumX server uses a self-signed TLS cert. The `trustAllCerts` option accepts any certificate for that server. This is acceptable because the Namecoin blockchain itself provides the trust anchor — we verify names against on-chain data, not the transport layer. A MITM could return stale data but cannot forge name registrations.
|
||||
- **Name expiry**: Namecoin names expire after ~36,000 blocks (~250 days) if not renewed. The current implementation does not check expiry. Future work should compare the name's `height` + `expiresIn` against the current block height.
|
||||
- **Server trust**: The client trusts that the ElectrumX server returns accurate transaction data. For higher assurance, SPV proof verification could be added in the future.
|
||||
- **Dynamic proxy evaluation**: Socket factory and server list are evaluated per-request (via lambdas), ensuring Tor setting changes take effect immediately without stale socket reuse.
|
||||
|
||||
## Files Changed
|
||||
|
||||
### New files (quartz/)
|
||||
- `quartz/.../nip05/namecoin/ElectrumxClient.kt` — ElectrumX TCP/TLS client with injected `SocketFactory` for proxy support
|
||||
- `quartz/.../nip05/namecoin/NamecoinNameResolver.kt` — Identifier parsing, value extraction, dynamic server selection
|
||||
- `quartz/.../nip05/namecoin/NamecoinLookupCache.kt` — LRU cache with TTL
|
||||
- `quartz/src/jvmTest/.../NamecoinNameResolverTest.kt` — Unit tests
|
||||
|
||||
### New files (amethyst/)
|
||||
- `amethyst/.../service/namecoin/NamecoinNameService.kt` — App singleton, initialized with proxy-aware ElectrumxClient
|
||||
- `amethyst/.../model/privacyOptions/ProxiedSocketFactory.kt` — `SocketFactory` that routes through SOCKS5 proxy (Tor)
|
||||
|
||||
### Modified files
|
||||
- `amethyst/.../AppModules.kt` — Wires up resolver with Tor-aware socket factory and server list provider
|
||||
- `amethyst/.../model/privacyOptions/RoleBasedHttpClientBuilder.kt` — Added `socketFactoryForNip05()` for proxy-aware socket creation
|
||||
- `amethyst/.../ui/screen/loggedIn/search/SearchBarViewModel.kt` — Namecoin search resolution
|
||||
- `quartz/.../nip05DnsIdentifiers/Nip05Client.kt` — Route `.bit` identifiers to Namecoin resolver
|
||||
|
||||
## Testing
|
||||
|
||||
### Unit tests
|
||||
```bash
|
||||
./gradlew :quartz:jvmTest --tests "*NamecoinNameResolverTest*"
|
||||
```
|
||||
|
||||
### Manual testing (emulator or device)
|
||||
|
||||
Build and install the debug APK:
|
||||
```bash
|
||||
./gradlew assemblePlayDebug
|
||||
adb install -r amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk
|
||||
```
|
||||
|
||||
**Search bar tests** — open the search bar and enter each of these:
|
||||
|
||||
| Search query | Expected result | What it tests |
|
||||
|---|---|---|
|
||||
| `m@testls.bit` | Resolves to Vitor Pamplona's profile | NIP-05 style `user@domain.bit` |
|
||||
| `testls.bit` | Resolves to Vitor Pamplona's profile (root `_` entry) | Bare domain `.bit` lookup |
|
||||
| `d/testls` | Resolves to Vitor Pamplona's profile | Direct `d/` namespace |
|
||||
| `id/someuser` | Resolves if registered on-chain | Direct `id/` namespace |
|
||||
|
||||
**Tor tests** — enable Tor and set "NIP-05 verifications via Tor" to on:
|
||||
1. Search for `m@testls.bit` — should resolve via onion server
|
||||
2. Verify no direct clearnet connections to ElectrumX servers (use `tcpdump`)
|
||||
3. Toggle Tor off — next search should use clearnet servers
|
||||
|
||||
**Verification test** — if a profile has a `.bit` address in its `nip05` field, the NIP-05 badge should verify via the blockchain instead of HTTP.
|
||||
|
||||
**Network verification** — to confirm ElectrumX calls are being made:
|
||||
```bash
|
||||
adb root
|
||||
adb shell tcpdump -i any -nn port 50002 or port 50006
|
||||
```
|
||||
With Tor off, you should see TCP connections to `162.212.154.52:50002` (electrumx.testls.space).
|
||||
With Tor on, you should see connections to the local Tor SOCKS port only (no direct ElectrumX connections).
|
||||
|
||||
### Live test data
|
||||
The name `d/testls` is registered on the Namecoin blockchain (block 551519+, last updated block 814278) with value:
|
||||
```json
|
||||
{
|
||||
"nostr": {
|
||||
"names": {
|
||||
"m": "6cdebccabda1dfa058ab85352a79509b592b2bdfa0370325e28ec1cb4f18667d"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This means:
|
||||
- `m@testls.bit` → resolves `m` entry → pubkey `6cdebcca...18667d`
|
||||
- `testls.bit` → resolves root `_` entry → falls back to first available entry
|
||||
- `d/testls` → same as `testls.bit` (root lookup)
|
||||
+554
@@ -0,0 +1,554 @@
|
||||
/*
|
||||
* 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.SocketFactory
|
||||
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 socketFactory: () -> SocketFactory = { SocketFactory.getDefault() },
|
||||
) {
|
||||
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 (clearnet). */
|
||||
val DEFAULT_SERVERS =
|
||||
listOf(
|
||||
ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true),
|
||||
ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true),
|
||||
ElectrumxServer("46.229.238.187", 57002, useSsl = true, trustAllCerts = true),
|
||||
)
|
||||
|
||||
/** Tor-preferred server list: onion primary, clearnet fallback. */
|
||||
val TOR_SERVERS =
|
||||
listOf(
|
||||
ElectrumxServer(
|
||||
"i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion",
|
||||
50002,
|
||||
useSsl = true,
|
||||
trustAllCerts = true,
|
||||
),
|
||||
ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true),
|
||||
ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true),
|
||||
)
|
||||
|
||||
private const val PROTOCOL_VERSION = "1.4"
|
||||
|
||||
/**
|
||||
* Namecoin names expire this many blocks after their last update.
|
||||
* From chainparams.cpp: consensus.nNameExpirationDepth = 36000
|
||||
* (~250 days at ~10 min/block).
|
||||
*/
|
||||
const val NAME_EXPIRE_DEPTH = 36_000
|
||||
|
||||
// Namecoin script opcodes
|
||||
private const val OP_NAME_UPDATE: Byte = 0x53 // OP_3 repurposed by Namecoin
|
||||
private const val OP_2DROP: Byte = 0x6d
|
||||
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 server in order until one succeeds.
|
||||
*
|
||||
* @param identifier Full Namecoin name, e.g. "d/example"
|
||||
* @param servers Ordered server list to try; defaults to [DEFAULT_SERVERS]
|
||||
*/
|
||||
suspend fun nameShowWithFallback(
|
||||
identifier: String,
|
||||
servers: List<ElectrumxServer> = DEFAULT_SERVERS,
|
||||
): NameShowResult? {
|
||||
for (server in 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. Get current block height to check name expiry
|
||||
val headersReq = buildRpcRequest("blockchain.headers.subscribe", emptyList<String>())
|
||||
writer.println(headersReq)
|
||||
val headersResponse = reader.readLine()
|
||||
val currentHeight = parseBlockHeight(headersResponse)
|
||||
|
||||
// 6. Check if the name has expired
|
||||
if (currentHeight != null && height > 0) {
|
||||
val blocksSinceUpdate = currentHeight - height
|
||||
if (blocksSinceUpdate >= NAME_EXPIRE_DEPTH) {
|
||||
return null // Name has expired
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Parse the name value from the transaction
|
||||
val result = parseNameFromTransaction(identifier, txHash, height, txResponse)
|
||||
// Populate expiresIn if we know the current height
|
||||
return if (result != null && currentHeight != null && height > 0) {
|
||||
result.copy(expiresIn = NAME_EXPIRE_DEPTH - (currentHeight - height))
|
||||
} else {
|
||||
result
|
||||
}
|
||||
} finally {
|
||||
runCatching { writer.close() }
|
||||
runCatching { reader.close() }
|
||||
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 block height from a `blockchain.headers.subscribe` response.
|
||||
*
|
||||
* Response format: {"result": {"height": 814300, "hex": "..."}, ...}
|
||||
*/
|
||||
private fun parseBlockHeight(raw: String?): Int? {
|
||||
if (raw == null) return null
|
||||
return try {
|
||||
val envelope = json.parseToJsonElement(raw).jsonObject
|
||||
val result = envelope["result"]?.jsonObject ?: return null
|
||||
result["height"]?.jsonPrimitive?.int
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the history response into a list of (txHash, height) pairs.
|
||||
*/
|
||||
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 {
|
||||
// Create the base socket through the injected factory, which
|
||||
// may route through a SOCKS proxy (e.g. Tor) if configured.
|
||||
val baseSocket =
|
||||
socketFactory().createSocket().apply {
|
||||
connect(InetSocketAddress(server.host, server.port), connectTimeoutMs.toInt())
|
||||
}
|
||||
|
||||
if (!server.useSsl) return baseSocket
|
||||
|
||||
// Upgrade to TLS over the already-connected (possibly proxied) socket.
|
||||
val sslFactory =
|
||||
if (server.trustAllCerts) {
|
||||
trustAllSslFactory()
|
||||
} else {
|
||||
SSLSocketFactory.getDefault() as SSLSocketFactory
|
||||
}
|
||||
return sslFactory.createSocket(baseSocket, server.host, server.port, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an SSLSocketFactory that accepts any certificate.
|
||||
* 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()
|
||||
}
|
||||
}
|
||||
+314
@@ -0,0 +1,314 @@
|
||||
/*
|
||||
* 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 serverListProvider: () -> List<ElectrumxServer> = { ElectrumxClient.DEFAULT_SERVERS },
|
||||
) {
|
||||
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, serverListProvider()) ?: 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