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:
+487
@@ -0,0 +1,487 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip05.namecoin
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import java.io.BufferedReader
|
||||
import java.io.InputStreamReader
|
||||
import java.io.PrintWriter
|
||||
import java.net.InetSocketAddress
|
||||
import java.net.Socket
|
||||
import java.security.MessageDigest
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.SSLSocketFactory
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
/**
|
||||
* Result of an ElectrumX name_show query.
|
||||
*
|
||||
* Maps to the JSON fields returned by Namecoin Core / Electrum-NMC:
|
||||
* { "name": "d/example", "value": "{...}", "txid": "abc...", "height": 12345, ... }
|
||||
*/
|
||||
@Serializable
|
||||
data class NameShowResult(
|
||||
val name: String,
|
||||
val value: String,
|
||||
val txid: String? = null,
|
||||
val height: Int? = null,
|
||||
val expiresIn: Int? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents a single ElectrumX server endpoint.
|
||||
*/
|
||||
data class ElectrumxServer(
|
||||
val host: String,
|
||||
val port: Int,
|
||||
val useSsl: Boolean = true,
|
||||
/** If true, accept any certificate (self-signed, expired, etc.) */
|
||||
val trustAllCerts: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Lightweight, query-only ElectrumX client for Namecoin name resolution.
|
||||
*
|
||||
* Connects over TCP/TLS to a Namecoin ElectrumX server and resolves
|
||||
* Namecoin names to their current values using the standard Electrum
|
||||
* protocol (scripthash-based lookups). Works with both the Namecoin
|
||||
* ElectrumX fork and stock ElectrumX pointed at a Namecoin node, as
|
||||
* long as the server has a name index.
|
||||
*
|
||||
* Resolution strategy:
|
||||
* 1. Build a canonical name index script for the identifier
|
||||
* 2. Compute the Electrum-style scripthash (reversed SHA-256)
|
||||
* 3. Query `blockchain.scripthash.get_history` to find the latest tx
|
||||
* 4. Fetch the raw transaction and parse the name value from the script
|
||||
*
|
||||
* Usage:
|
||||
* ```
|
||||
* val client = ElectrumxClient()
|
||||
* val result = client.nameShow("d/example", server)
|
||||
* ```
|
||||
*/
|
||||
class ElectrumxClient(
|
||||
private val connectTimeoutMs: Long = 10_000L,
|
||||
private val readTimeoutMs: Long = 15_000L,
|
||||
) {
|
||||
private val json =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
private val requestId = AtomicInteger(0)
|
||||
private val mutex = Mutex()
|
||||
|
||||
companion object {
|
||||
/** Well-known public Namecoin ElectrumX servers. */
|
||||
val DEFAULT_SERVERS =
|
||||
listOf(
|
||||
ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true),
|
||||
ElectrumxServer("ulrichard.ch", 50006, useSsl = true),
|
||||
ElectrumxServer("nmc2.lelux.fi", 50006, useSsl = true),
|
||||
)
|
||||
|
||||
private const val PROTOCOL_VERSION = "1.4"
|
||||
|
||||
// Namecoin script opcodes
|
||||
private const val OP_NAME_UPDATE: Byte = 0x53 // OP_3 repurposed by Namecoin
|
||||
private const val OP_2DROP: Byte = 0x6d
|
||||
private const val OP_DROP: Byte = 0x75
|
||||
private const val OP_RETURN: Byte = 0x6a
|
||||
private const val OP_PUSHDATA1: Byte = 0x4c
|
||||
private const val OP_PUSHDATA2: Byte = 0x4d
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a name_show lookup against the given ElectrumX server.
|
||||
*
|
||||
* Uses the scripthash-based approach: computes the name's canonical
|
||||
* index script hash, queries transaction history, and parses the
|
||||
* name value from the latest transaction's output script.
|
||||
*
|
||||
* @param identifier Full Namecoin name, e.g. "d/example" or "id/alice"
|
||||
* @param server ElectrumX server to query
|
||||
* @return [NameShowResult] on success, null if the name does not exist
|
||||
* or the server is unreachable
|
||||
*/
|
||||
suspend fun nameShow(
|
||||
identifier: String,
|
||||
server: ElectrumxServer = DEFAULT_SERVERS.first(),
|
||||
): NameShowResult? =
|
||||
withContext(Dispatchers.IO) {
|
||||
mutex.withLock {
|
||||
try {
|
||||
connectAndQuery(identifier, server)
|
||||
} catch (e: Exception) {
|
||||
// Log but don't crash — callers handle null gracefully.
|
||||
e.printStackTrace()
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try each default server in order until one succeeds.
|
||||
*/
|
||||
suspend fun nameShowWithFallback(identifier: String): NameShowResult? {
|
||||
for (server in DEFAULT_SERVERS) {
|
||||
val result = nameShow(identifier, server)
|
||||
if (result != null) return result
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ── internals ──────────────────────────────────────────────────────
|
||||
|
||||
private fun connectAndQuery(
|
||||
identifier: String,
|
||||
server: ElectrumxServer,
|
||||
): NameShowResult? {
|
||||
val socket = createSocket(server)
|
||||
socket.soTimeout = readTimeoutMs.toInt()
|
||||
val writer = PrintWriter(socket.getOutputStream(), true)
|
||||
val reader = BufferedReader(InputStreamReader(socket.getInputStream()))
|
||||
|
||||
try {
|
||||
// 1. Negotiate protocol version
|
||||
val versionReq = buildRpcRequest("server.version", listOf("AmethystNMC/0.1", PROTOCOL_VERSION))
|
||||
writer.println(versionReq)
|
||||
reader.readLine() // consume version response
|
||||
|
||||
// 2. Compute the canonical name index scripthash
|
||||
val nameScript = buildNameIndexScript(identifier.toByteArray(Charsets.US_ASCII))
|
||||
val scriptHash = electrumScriptHash(nameScript)
|
||||
|
||||
// 3. Get transaction history for this name
|
||||
val historyReq = buildRpcRequest("blockchain.scripthash.get_history", listOf(scriptHash))
|
||||
writer.println(historyReq)
|
||||
val historyResponse = reader.readLine() ?: return null
|
||||
val historyEntries = parseHistoryResponse(historyResponse) ?: return null
|
||||
if (historyEntries.isEmpty()) return null
|
||||
|
||||
// 4. Get the latest transaction (last entry = most recent update)
|
||||
val latestEntry = historyEntries.last()
|
||||
val txHash = latestEntry.first
|
||||
val height = latestEntry.second
|
||||
|
||||
val txReq = buildRpcRequest("blockchain.transaction.get", listOf(txHash, true))
|
||||
writer.println(txReq)
|
||||
val txResponse = reader.readLine() ?: return null
|
||||
|
||||
// 5. Parse the name value from the transaction
|
||||
return parseNameFromTransaction(identifier, txHash, height, txResponse)
|
||||
} finally {
|
||||
runCatching { writer.close() }
|
||||
runCatching { reader.close() }
|
||||
runCatching { socket.close() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the canonical script used by ElectrumX to index Namecoin names.
|
||||
*
|
||||
* Format: OP_NAME_UPDATE <push(name)> <push(empty)> OP_2DROP OP_DROP OP_RETURN
|
||||
*
|
||||
* This matches the `build_name_index_script` method in the Namecoin
|
||||
* ElectrumX fork (electrumx/lib/coins.py).
|
||||
*/
|
||||
private fun buildNameIndexScript(nameBytes: ByteArray): ByteArray {
|
||||
val result = mutableListOf<Byte>()
|
||||
result.add(OP_NAME_UPDATE)
|
||||
result.addAll(pushData(nameBytes).toList())
|
||||
result.addAll(pushData(byteArrayOf()).toList()) // empty value
|
||||
result.add(OP_2DROP)
|
||||
result.add(OP_DROP)
|
||||
result.add(OP_RETURN)
|
||||
return result.toByteArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bitcoin-style push data encoding.
|
||||
*/
|
||||
private fun pushData(data: ByteArray): ByteArray {
|
||||
val len = data.size
|
||||
return when {
|
||||
len < 0x4c -> {
|
||||
byteArrayOf(len.toByte()) + data
|
||||
}
|
||||
|
||||
len <= 0xff -> {
|
||||
byteArrayOf(OP_PUSHDATA1, len.toByte()) + data
|
||||
}
|
||||
|
||||
else -> {
|
||||
val lenBytes = byteArrayOf((len and 0xff).toByte(), ((len shr 8) and 0xff).toByte())
|
||||
byteArrayOf(OP_PUSHDATA2) + lenBytes + data
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Electrum protocol scripthash: SHA-256 of the script, byte-reversed, hex-encoded.
|
||||
*/
|
||||
private fun electrumScriptHash(script: ByteArray): String {
|
||||
val digest = MessageDigest.getInstance("SHA-256").digest(script)
|
||||
return digest.reversedArray().joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the history response into a list of (txHash, height) pairs.
|
||||
*/
|
||||
private fun parseHistoryResponse(raw: String): List<Pair<String, Int>>? {
|
||||
val envelope = json.parseToJsonElement(raw).jsonObject
|
||||
val error = envelope["error"]
|
||||
if (error != null && error !is kotlinx.serialization.json.JsonNull) return null
|
||||
|
||||
val result = envelope["result"]?.jsonArray ?: return null
|
||||
return result.mapNotNull { entry ->
|
||||
val obj = entry.jsonObject
|
||||
val txHash = obj["tx_hash"]?.jsonPrimitive?.content ?: return@mapNotNull null
|
||||
val height = obj["height"]?.jsonPrimitive?.int ?: return@mapNotNull null
|
||||
txHash to height
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a Namecoin name and value from a verbose transaction response.
|
||||
*
|
||||
* Scans each output for a NAME_UPDATE script (starts with OP_3 = 0x53),
|
||||
* then extracts the name and value from the script's push data.
|
||||
*/
|
||||
private fun parseNameFromTransaction(
|
||||
identifier: String,
|
||||
txHash: String,
|
||||
height: Int,
|
||||
raw: String,
|
||||
): NameShowResult? {
|
||||
val envelope = json.parseToJsonElement(raw).jsonObject
|
||||
val error = envelope["error"]
|
||||
if (error != null && error !is kotlinx.serialization.json.JsonNull) return null
|
||||
|
||||
val result = envelope["result"]?.jsonObject ?: return null
|
||||
val vouts = result["vout"]?.jsonArray ?: return null
|
||||
|
||||
for (vout in vouts) {
|
||||
val scriptHex =
|
||||
vout.jsonObject["scriptPubKey"]
|
||||
?.jsonObject
|
||||
?.get("hex")
|
||||
?.jsonPrimitive
|
||||
?.content
|
||||
?: continue
|
||||
|
||||
// NAME_UPDATE scripts start with OP_3 (0x53)
|
||||
if (!scriptHex.startsWith("53")) continue
|
||||
|
||||
val scriptBytes = hexToBytes(scriptHex)
|
||||
val parsed = parseNameScript(scriptBytes) ?: continue
|
||||
|
||||
// Verify this is the name we're looking for
|
||||
if (parsed.first == identifier) {
|
||||
return NameShowResult(
|
||||
name = parsed.first,
|
||||
value = parsed.second,
|
||||
txid = txHash,
|
||||
height = height,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a NAME_UPDATE script to extract the name and value.
|
||||
*
|
||||
* Script format: OP_NAME_UPDATE <push(name)> <push(value)> OP_2DROP OP_DROP <address_script>
|
||||
*
|
||||
* @return Pair of (name, value) as strings, or null if parsing fails
|
||||
*/
|
||||
private fun parseNameScript(script: ByteArray): Pair<String, String>? {
|
||||
if (script.isEmpty() || script[0] != OP_NAME_UPDATE) return null
|
||||
|
||||
var pos = 1
|
||||
|
||||
// Read name
|
||||
val (nameBytes, newPos1) = readPushData(script, pos) ?: return null
|
||||
pos = newPos1
|
||||
|
||||
// Read value
|
||||
val (valueBytes, _) = readPushData(script, pos) ?: return null
|
||||
|
||||
val name = String(nameBytes, Charsets.US_ASCII)
|
||||
val value = String(valueBytes, Charsets.UTF_8)
|
||||
return name to value
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a push-data encoded byte sequence from the script at the given position.
|
||||
*
|
||||
* @return Pair of (data, nextPosition), or null if the script is malformed
|
||||
*/
|
||||
private fun readPushData(
|
||||
script: ByteArray,
|
||||
pos: Int,
|
||||
): Pair<ByteArray, Int>? {
|
||||
if (pos >= script.size) return null
|
||||
|
||||
val opcode = script[pos].toInt() and 0xff
|
||||
return when {
|
||||
opcode == 0 -> {
|
||||
// OP_0 / push empty
|
||||
byteArrayOf() to (pos + 1)
|
||||
}
|
||||
|
||||
opcode < 0x4c -> {
|
||||
// Direct push: opcode is the length
|
||||
val end = pos + 1 + opcode
|
||||
if (end > script.size) return null
|
||||
script.copyOfRange(pos + 1, end) to end
|
||||
}
|
||||
|
||||
opcode == 0x4c -> {
|
||||
// OP_PUSHDATA1: next byte is length
|
||||
if (pos + 2 > script.size) return null
|
||||
val len = script[pos + 1].toInt() and 0xff
|
||||
val end = pos + 2 + len
|
||||
if (end > script.size) return null
|
||||
script.copyOfRange(pos + 2, end) to end
|
||||
}
|
||||
|
||||
opcode == 0x4d -> {
|
||||
// OP_PUSHDATA2: next 2 bytes are length (little-endian)
|
||||
if (pos + 3 > script.size) return null
|
||||
val len =
|
||||
(script[pos + 1].toInt() and 0xff) or
|
||||
((script[pos + 2].toInt() and 0xff) shl 8)
|
||||
val end = pos + 3 + len
|
||||
if (end > script.size) return null
|
||||
script.copyOfRange(pos + 3, end) to end
|
||||
}
|
||||
|
||||
else -> {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun hexToBytes(hex: String): ByteArray {
|
||||
val len = hex.length
|
||||
val data = ByteArray(len / 2)
|
||||
for (i in 0 until len step 2) {
|
||||
data[i / 2] =
|
||||
(
|
||||
(Character.digit(hex[i], 16) shl 4) +
|
||||
Character.digit(hex[i + 1], 16)
|
||||
).toByte()
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private fun createSocket(server: ElectrumxServer): Socket =
|
||||
if (server.useSsl) {
|
||||
val factory =
|
||||
if (server.trustAllCerts) {
|
||||
trustAllSslFactory()
|
||||
} else {
|
||||
SSLSocketFactory.getDefault() as SSLSocketFactory
|
||||
}
|
||||
factory.createSocket().apply {
|
||||
connect(InetSocketAddress(server.host, server.port), connectTimeoutMs.toInt())
|
||||
}
|
||||
} else {
|
||||
Socket().apply {
|
||||
connect(InetSocketAddress(server.host, server.port), connectTimeoutMs.toInt())
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an SSLSocketFactory that accepts any certificate.
|
||||
* Used for servers with self-signed certificates.
|
||||
*/
|
||||
private fun trustAllSslFactory(): SSLSocketFactory {
|
||||
val trustAllCerts =
|
||||
arrayOf<TrustManager>(
|
||||
object : X509TrustManager {
|
||||
override fun checkClientTrusted(
|
||||
chain: Array<java.security.cert.X509Certificate>,
|
||||
authType: String,
|
||||
) {}
|
||||
|
||||
override fun checkServerTrusted(
|
||||
chain: Array<java.security.cert.X509Certificate>,
|
||||
authType: String,
|
||||
) {}
|
||||
|
||||
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
|
||||
},
|
||||
)
|
||||
val sslContext = SSLContext.getInstance("TLS")
|
||||
sslContext.init(null, trustAllCerts, java.security.SecureRandom())
|
||||
return sslContext.socketFactory
|
||||
}
|
||||
|
||||
private fun buildRpcRequest(
|
||||
method: String,
|
||||
params: List<Any>,
|
||||
): String {
|
||||
val id = requestId.incrementAndGet()
|
||||
val obj =
|
||||
buildJsonObject {
|
||||
put("jsonrpc", "2.0")
|
||||
put("id", id)
|
||||
put("method", method)
|
||||
put(
|
||||
"params",
|
||||
json.encodeToJsonElement(
|
||||
kotlinx.serialization.builtins.ListSerializer(
|
||||
kotlinx.serialization.json.JsonElement
|
||||
.serializer(),
|
||||
),
|
||||
params.map {
|
||||
when (it) {
|
||||
is Boolean -> JsonPrimitive(it)
|
||||
is Number -> JsonPrimitive(it)
|
||||
else -> JsonPrimitive(it.toString())
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
return json.encodeToString(JsonObject.serializer(), obj)
|
||||
}
|
||||
}
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip05.namecoin
|
||||
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
data class CachedResult(
|
||||
val result: NamecoinNostrResult?,
|
||||
val timestamp: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
class NamecoinLookupCache(
|
||||
private val maxEntries: Int = 500,
|
||||
private val ttlMs: Long = 3_600_000L, // 1 hour
|
||||
) {
|
||||
private val cache = LinkedHashMap<String, CachedResult>(maxEntries, 0.75f, true)
|
||||
private val mutex = Mutex()
|
||||
|
||||
/**
|
||||
* Normalised cache key from the user's raw input.
|
||||
*/
|
||||
private fun cacheKey(identifier: String): String = identifier.trim().lowercase()
|
||||
|
||||
suspend fun get(identifier: String): CachedResult? =
|
||||
mutex.withLock {
|
||||
val key = cacheKey(identifier)
|
||||
val entry = cache[key] ?: return null
|
||||
val age = System.currentTimeMillis() - entry.timestamp
|
||||
if (age > ttlMs) {
|
||||
cache.remove(key)
|
||||
return null
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
suspend fun put(
|
||||
identifier: String,
|
||||
result: NamecoinNostrResult?,
|
||||
) = mutex.withLock {
|
||||
val key = cacheKey(identifier)
|
||||
if (cache.size >= maxEntries) {
|
||||
// Remove eldest (LRU) entry
|
||||
val eldest = cache.entries.firstOrNull()
|
||||
if (eldest != null) cache.remove(eldest.key)
|
||||
}
|
||||
cache[key] = CachedResult(result)
|
||||
}
|
||||
|
||||
suspend fun invalidate(identifier: String) =
|
||||
mutex.withLock {
|
||||
cache.remove(cacheKey(identifier))
|
||||
}
|
||||
|
||||
suspend fun clear() =
|
||||
mutex.withLock {
|
||||
cache.clear()
|
||||
}
|
||||
}
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip05.namecoin
|
||||
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
|
||||
/**
|
||||
* Result of resolving a Namecoin name to Nostr identity data.
|
||||
*/
|
||||
data class NamecoinNostrResult(
|
||||
/** Hex-encoded 32-byte Schnorr public key */
|
||||
val pubkey: String,
|
||||
/** Optional relay URLs where this user can be found */
|
||||
val relays: List<String> = emptyList(),
|
||||
/** The Namecoin name that was resolved (e.g. "d/example") */
|
||||
val namecoinName: String,
|
||||
/** The local-part that was matched (e.g. "alice" or "_") */
|
||||
val localPart: String = "_",
|
||||
)
|
||||
|
||||
/**
|
||||
* Resolves Namecoin names to Nostr public keys.
|
||||
*
|
||||
* This is the primary entry point for Namecoin→Nostr resolution.
|
||||
* It is designed to be used alongside Amethyst's existing NIP-05
|
||||
* verifier: if an identifier ends with `.bit`, it should be routed
|
||||
* here instead of to the HTTP-based NIP-05 path.
|
||||
*/
|
||||
class NamecoinNameResolver(
|
||||
private val electrumxClient: ElectrumxClient = ElectrumxClient(),
|
||||
private val lookupTimeoutMs: Long = 20_000L,
|
||||
) {
|
||||
private val json =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val HEX_PUBKEY_REGEX = Regex("^[0-9a-fA-F]{64}$")
|
||||
|
||||
/**
|
||||
* Check whether an identifier should be routed to Namecoin
|
||||
* resolution rather than standard NIP-05.
|
||||
*/
|
||||
fun isNamecoinIdentifier(identifier: String): Boolean {
|
||||
val normalized = identifier.trim().lowercase()
|
||||
return normalized.endsWith(".bit") ||
|
||||
normalized.startsWith("d/") ||
|
||||
normalized.startsWith("id/")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a user-supplied identifier to a Nostr pubkey via Namecoin.
|
||||
*
|
||||
* @param identifier User input, e.g. "alice@example.bit", "id/alice", "example.bit"
|
||||
* @return [NamecoinNostrResult] on success, null if resolution failed
|
||||
*/
|
||||
suspend fun resolve(identifier: String): NamecoinNostrResult? {
|
||||
val parsed = parseIdentifier(identifier) ?: return null
|
||||
return withTimeoutOrNull(lookupTimeoutMs) {
|
||||
performLookup(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Identifier Parsing ─────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parsed representation of a Namecoin lookup request.
|
||||
*/
|
||||
private data class ParsedIdentifier(
|
||||
/** The Namecoin name to query, e.g. "d/example" or "id/alice" */
|
||||
val namecoinName: String,
|
||||
/** The local-part to look up within the name's value.
|
||||
* For d/ names: the user part (or "_" for root).
|
||||
* For id/ names: always "_". */
|
||||
val localPart: String,
|
||||
/** Which namespace: DOMAIN or IDENTITY */
|
||||
val namespace: Namespace,
|
||||
)
|
||||
|
||||
private enum class Namespace { DOMAIN, IDENTITY }
|
||||
|
||||
/**
|
||||
* Parse a user-supplied string into a structured lookup request.
|
||||
*
|
||||
* Accepted formats:
|
||||
* "alice@example.bit" → d/example, localPart=alice
|
||||
* "_@example.bit" → d/example, localPart=_
|
||||
* "example.bit" → d/example, localPart=_
|
||||
* "d/example" → d/example, localPart=_
|
||||
* "id/alice" → id/alice, localPart=_
|
||||
*/
|
||||
private fun parseIdentifier(raw: String): ParsedIdentifier? {
|
||||
val input = raw.trim()
|
||||
|
||||
// Direct namespace references
|
||||
if (input.startsWith("d/", ignoreCase = true)) {
|
||||
return ParsedIdentifier(
|
||||
namecoinName = input.lowercase(),
|
||||
localPart = "_",
|
||||
namespace = Namespace.DOMAIN,
|
||||
)
|
||||
}
|
||||
if (input.startsWith("id/", ignoreCase = true)) {
|
||||
return ParsedIdentifier(
|
||||
namecoinName = input.lowercase(),
|
||||
localPart = "_",
|
||||
namespace = Namespace.IDENTITY,
|
||||
)
|
||||
}
|
||||
|
||||
// NIP-05 style: user@domain.bit
|
||||
if (input.contains("@") && input.endsWith(".bit", ignoreCase = true)) {
|
||||
val parts = input.split("@", limit = 2)
|
||||
if (parts.size != 2) return null
|
||||
val localPart = parts[0].lowercase().ifEmpty { "_" }
|
||||
val domain = parts[1].removeSuffix(".bit").lowercase()
|
||||
if (domain.isEmpty()) return null
|
||||
return ParsedIdentifier(
|
||||
namecoinName = "d/$domain",
|
||||
localPart = localPart,
|
||||
namespace = Namespace.DOMAIN,
|
||||
)
|
||||
}
|
||||
|
||||
// Bare domain: example.bit
|
||||
if (input.endsWith(".bit", ignoreCase = true)) {
|
||||
val domain = input.removeSuffix(".bit").lowercase()
|
||||
if (domain.isEmpty()) return null
|
||||
return ParsedIdentifier(
|
||||
namecoinName = "d/$domain",
|
||||
localPart = "_",
|
||||
namespace = Namespace.DOMAIN,
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Lookup & Value Parsing ─────────────────────────────────────────
|
||||
|
||||
private suspend fun performLookup(parsed: ParsedIdentifier): NamecoinNostrResult? {
|
||||
val nameResult = electrumxClient.nameShowWithFallback(parsed.namecoinName) ?: return null
|
||||
val valueJson = tryParseJson(nameResult.value) ?: return null
|
||||
|
||||
return when (parsed.namespace) {
|
||||
Namespace.DOMAIN -> extractFromDomainValue(valueJson, parsed)
|
||||
Namespace.IDENTITY -> extractFromIdentityValue(valueJson, parsed)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Nostr data from a `d/` domain value.
|
||||
*
|
||||
* Supports:
|
||||
* { "nostr": "hex-pubkey" } → simple form
|
||||
* { "nostr": { "names": { "alice": "hex" }, ... } } → extended NIP-05-like form
|
||||
*/
|
||||
private fun extractFromDomainValue(
|
||||
value: JsonObject,
|
||||
parsed: ParsedIdentifier,
|
||||
): NamecoinNostrResult? {
|
||||
val nostrField = value["nostr"] ?: return null
|
||||
|
||||
// Simple form: "nostr": "hex-pubkey"
|
||||
if (nostrField is JsonPrimitive && nostrField.isString) {
|
||||
val pubkey = nostrField.content
|
||||
if (parsed.localPart == "_" && isValidPubkey(pubkey)) {
|
||||
return NamecoinNostrResult(
|
||||
pubkey = pubkey.lowercase(),
|
||||
namecoinName = parsed.namecoinName,
|
||||
localPart = "_",
|
||||
)
|
||||
}
|
||||
// Simple form only supports root — if a non-root local-part
|
||||
// was requested, we can't resolve it.
|
||||
if (parsed.localPart != "_") return null
|
||||
}
|
||||
|
||||
// Extended form: "nostr": { "names": {...}, "relays": {...} }
|
||||
if (nostrField is JsonObject) {
|
||||
val names = nostrField["names"]?.jsonObject ?: return null
|
||||
val pubkeyElem = names[parsed.localPart] ?: names["_"] // fall back to root
|
||||
val pubkey = (pubkeyElem as? JsonPrimitive)?.content ?: return null
|
||||
if (!isValidPubkey(pubkey)) return null
|
||||
|
||||
val relays = extractRelays(nostrField, pubkey)
|
||||
return NamecoinNostrResult(
|
||||
pubkey = pubkey.lowercase(),
|
||||
relays = relays,
|
||||
namecoinName = parsed.namecoinName,
|
||||
localPart = parsed.localPart,
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract Nostr data from an `id/` identity value.
|
||||
*
|
||||
* The id/ namespace stores general identity data. We look for:
|
||||
* { "nostr": "hex-pubkey" }
|
||||
* { "nostr": { "pubkey": "hex", "relays": [...] } }
|
||||
*/
|
||||
private fun extractFromIdentityValue(
|
||||
value: JsonObject,
|
||||
parsed: ParsedIdentifier,
|
||||
): NamecoinNostrResult? {
|
||||
val nostrField = value["nostr"] ?: return null
|
||||
|
||||
// Simple: "nostr": "hex-pubkey"
|
||||
if (nostrField is JsonPrimitive && nostrField.isString) {
|
||||
val pubkey = nostrField.content
|
||||
if (isValidPubkey(pubkey)) {
|
||||
return NamecoinNostrResult(
|
||||
pubkey = pubkey.lowercase(),
|
||||
namecoinName = parsed.namecoinName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Object form: "nostr": { "pubkey": "hex", "relays": [...] }
|
||||
if (nostrField is JsonObject) {
|
||||
// Try "pubkey" field
|
||||
val pubkey = (nostrField["pubkey"] as? JsonPrimitive)?.content
|
||||
if (pubkey != null && isValidPubkey(pubkey)) {
|
||||
val relays =
|
||||
try {
|
||||
nostrField["relays"]?.jsonArray?.mapNotNull {
|
||||
(it as? JsonPrimitive)?.content
|
||||
} ?: emptyList()
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
return NamecoinNostrResult(
|
||||
pubkey = pubkey.lowercase(),
|
||||
relays = relays,
|
||||
namecoinName = parsed.namecoinName,
|
||||
)
|
||||
}
|
||||
|
||||
// Also try NIP-05-like "names" structure for id/ names
|
||||
val names = nostrField["names"]?.jsonObject
|
||||
if (names != null) {
|
||||
val rootPubkey = (names["_"] as? JsonPrimitive)?.content
|
||||
if (rootPubkey != null && isValidPubkey(rootPubkey)) {
|
||||
val relays = extractRelays(nostrField, rootPubkey)
|
||||
return NamecoinNostrResult(
|
||||
pubkey = rootPubkey.lowercase(),
|
||||
relays = relays,
|
||||
namecoinName = parsed.namecoinName,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
private fun extractRelays(
|
||||
nostrObj: JsonObject,
|
||||
pubkey: String,
|
||||
): List<String> {
|
||||
return try {
|
||||
val relaysMap = nostrObj["relays"]?.jsonObject ?: return emptyList()
|
||||
val relayArray =
|
||||
relaysMap[pubkey.lowercase()]?.jsonArray
|
||||
?: relaysMap[pubkey]?.jsonArray
|
||||
?: return emptyList()
|
||||
relayArray.mapNotNull { (it as? JsonPrimitive)?.content }
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun tryParseJson(raw: String): JsonObject? =
|
||||
try {
|
||||
json.parseToJsonElement(raw).jsonObject
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun isValidPubkey(s: String): Boolean = HEX_PUBKEY_REGEX.matches(s)
|
||||
}
|
||||
+17
-1
@@ -21,6 +21,7 @@
|
||||
package com.vitorpamplona.quartz.nip05DnsIdentifiers
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
data class Nip05KeyInfo(
|
||||
@@ -30,6 +31,7 @@ data class Nip05KeyInfo(
|
||||
|
||||
class Nip05Client(
|
||||
val fetcher: Nip05Fetcher,
|
||||
val namecoinResolver: NamecoinNameResolver? = null,
|
||||
) {
|
||||
val parser = Nip05Parser()
|
||||
|
||||
@@ -37,6 +39,12 @@ class Nip05Client(
|
||||
nip05: Nip05Id,
|
||||
hexKey: HexKey,
|
||||
): Boolean {
|
||||
// Namecoin: route .bit domains to blockchain verification
|
||||
if (namecoinResolver != null && NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())) {
|
||||
val result = namecoinResolver.resolve(nip05.toValue())
|
||||
return result?.pubkey == hexKey
|
||||
}
|
||||
|
||||
val json = fetchNip05Data(nip05)
|
||||
|
||||
val key =
|
||||
@@ -54,7 +62,15 @@ class Nip05Client(
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun get(nip05: Nip05Id) = parser.parseHexKeyAndRelays(nip05, fetchNip05Data(nip05))
|
||||
suspend fun get(nip05: Nip05Id): Nip05KeyInfo? {
|
||||
// Namecoin: route .bit domains to blockchain resolution
|
||||
if (namecoinResolver != null && NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())) {
|
||||
val result = namecoinResolver.resolve(nip05.toValue()) ?: return null
|
||||
return Nip05KeyInfo(result.pubkey, result.relays)
|
||||
}
|
||||
|
||||
return parser.parseHexKeyAndRelays(nip05, fetchNip05Data(nip05))
|
||||
}
|
||||
|
||||
suspend fun load(nip05: Nip05Id) = parser.parse(fetchNip05Data(nip05))
|
||||
|
||||
|
||||
+281
@@ -0,0 +1,281 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.nip05.namecoin
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class NamecoinNameResolverTest {
|
||||
// ── isNamecoinIdentifier ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `recognizes dot-bit domains`() {
|
||||
assertTrue(NamecoinNameResolver.isNamecoinIdentifier("example.bit"))
|
||||
assertTrue(NamecoinNameResolver.isNamecoinIdentifier("alice@example.bit"))
|
||||
assertTrue(NamecoinNameResolver.isNamecoinIdentifier("_@example.bit"))
|
||||
assertTrue(NamecoinNameResolver.isNamecoinIdentifier("EXAMPLE.BIT"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recognizes d-slash names`() {
|
||||
assertTrue(NamecoinNameResolver.isNamecoinIdentifier("d/example"))
|
||||
assertTrue(NamecoinNameResolver.isNamecoinIdentifier("D/Example"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recognizes id-slash names`() {
|
||||
assertTrue(NamecoinNameResolver.isNamecoinIdentifier("id/alice"))
|
||||
assertTrue(NamecoinNameResolver.isNamecoinIdentifier("ID/Alice"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects non-namecoin identifiers`() {
|
||||
assertFalse(NamecoinNameResolver.isNamecoinIdentifier("[email protected]"))
|
||||
assertFalse(NamecoinNameResolver.isNamecoinIdentifier("npub1abc"))
|
||||
assertFalse(NamecoinNameResolver.isNamecoinIdentifier("some random text"))
|
||||
assertFalse(NamecoinNameResolver.isNamecoinIdentifier(""))
|
||||
}
|
||||
|
||||
// ── Value format: simple pubkey in d/ ──────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `parses simple nostr field from domain value`() {
|
||||
val value = """{"nostr":"b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"}"""
|
||||
val result = extractNostrFromValue(value, "d/example", "_")
|
||||
assertNotNull(result)
|
||||
assertEquals("b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9", result!!.pubkey)
|
||||
}
|
||||
|
||||
// ── Value format: extended NIP-05-like in d/ ───────────────────────
|
||||
|
||||
@Test
|
||||
fun `parses extended nostr names from domain value`() {
|
||||
val value = """{
|
||||
"nostr": {
|
||||
"names": {
|
||||
"_": "aaaa000000000000000000000000000000000000000000000000000000000001",
|
||||
"alice": "bbbb000000000000000000000000000000000000000000000000000000000002"
|
||||
},
|
||||
"relays": {
|
||||
"bbbb000000000000000000000000000000000000000000000000000000000002": [
|
||||
"wss://relay.example.com"
|
||||
]
|
||||
}
|
||||
}
|
||||
}"""
|
||||
|
||||
// Root lookup
|
||||
val rootResult = extractNostrFromValue(value, "d/example", "_")
|
||||
assertNotNull(rootResult)
|
||||
assertEquals("aaaa000000000000000000000000000000000000000000000000000000000001", rootResult!!.pubkey)
|
||||
|
||||
// Named lookup
|
||||
val aliceResult = extractNostrFromValue(value, "d/example", "alice")
|
||||
assertNotNull(aliceResult)
|
||||
assertEquals("bbbb000000000000000000000000000000000000000000000000000000000002", aliceResult!!.pubkey)
|
||||
assertEquals(listOf("wss://relay.example.com"), aliceResult.relays)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to root when named user not found`() {
|
||||
val value = """{
|
||||
"nostr": {
|
||||
"names": {
|
||||
"_": "aaaa000000000000000000000000000000000000000000000000000000000001"
|
||||
}
|
||||
}
|
||||
}"""
|
||||
|
||||
val result = extractNostrFromValue(value, "d/example", "nonexistent")
|
||||
assertNotNull(result)
|
||||
assertEquals("aaaa000000000000000000000000000000000000000000000000000000000001", result!!.pubkey)
|
||||
}
|
||||
|
||||
// ── Value format: id/ namespace ────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `parses simple nostr field from identity value`() {
|
||||
val value = """{
|
||||
"nostr": "cccc000000000000000000000000000000000000000000000000000000000003",
|
||||
"email": "[email protected]"
|
||||
}"""
|
||||
val result = extractNostrFromIdentityValue(value, "id/alice")
|
||||
assertNotNull(result)
|
||||
assertEquals("cccc000000000000000000000000000000000000000000000000000000000003", result!!.pubkey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses object nostr field from identity value`() {
|
||||
val value = """{
|
||||
"nostr": {
|
||||
"pubkey": "dddd000000000000000000000000000000000000000000000000000000000004",
|
||||
"relays": ["wss://relay.example.com", "wss://relay2.example.com"]
|
||||
}
|
||||
}"""
|
||||
val result = extractNostrFromIdentityValue(value, "id/bob")
|
||||
assertNotNull(result)
|
||||
assertEquals("dddd000000000000000000000000000000000000000000000000000000000004", result!!.pubkey)
|
||||
assertEquals(2, result.relays.size)
|
||||
}
|
||||
|
||||
// ── Invalid data ───────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `rejects invalid pubkey lengths`() {
|
||||
val value = """{"nostr":"tooshort"}"""
|
||||
val result = extractNostrFromValue(value, "d/bad", "_")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects non-hex pubkeys`() {
|
||||
val value = """{"nostr":"zzzz000000000000000000000000000000000000000000000000000000000000"}"""
|
||||
val result = extractNostrFromValue(value, "d/bad", "_")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles missing nostr field`() {
|
||||
val value = """{"ip":"1.2.3.4","map":{"www":{"ip":"1.2.3.4"}}}"""
|
||||
val result = extractNostrFromValue(value, "d/example", "_")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handles malformed JSON gracefully`() {
|
||||
val value = "not json at all"
|
||||
val result = extractNostrFromValue(value, "d/broken", "_")
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
// ── Test helpers ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Directly test value parsing without network access.
|
||||
* Simulates what NamecoinNameResolver does after receiving a name_show result.
|
||||
*/
|
||||
private fun extractNostrFromValue(
|
||||
jsonValue: String,
|
||||
namecoinName: String,
|
||||
localPart: String,
|
||||
): NamecoinNostrResult? {
|
||||
val json =
|
||||
kotlinx.serialization.json.Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
val obj =
|
||||
try {
|
||||
json.parseToJsonElement(jsonValue).jsonObject
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
|
||||
val nostrField = obj["nostr"] ?: return null
|
||||
|
||||
// Simple form
|
||||
if (nostrField is kotlinx.serialization.json.JsonPrimitive && nostrField.isString) {
|
||||
val pubkey = nostrField.content
|
||||
if (localPart == "_" && pubkey.matches(Regex("^[0-9a-fA-F]{64}$"))) {
|
||||
return NamecoinNostrResult(pubkey = pubkey.lowercase(), namecoinName = namecoinName)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Extended form
|
||||
if (nostrField is kotlinx.serialization.json.JsonObject) {
|
||||
val names = nostrField["names"]?.jsonObject ?: return null
|
||||
val pubkeyElem = names[localPart] ?: names["_"] ?: return null
|
||||
val pubkey = (pubkeyElem as? kotlinx.serialization.json.JsonPrimitive)?.content ?: return null
|
||||
if (!pubkey.matches(Regex("^[0-9a-fA-F]{64}$"))) return null
|
||||
|
||||
val relays =
|
||||
try {
|
||||
val relaysMap = nostrField["relays"]?.jsonObject
|
||||
relaysMap?.get(pubkey.lowercase())?.jsonArray?.mapNotNull {
|
||||
(it as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||
} ?: emptyList()
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
return NamecoinNostrResult(
|
||||
pubkey = pubkey.lowercase(),
|
||||
relays = relays,
|
||||
namecoinName = namecoinName,
|
||||
localPart = localPart,
|
||||
)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun extractNostrFromIdentityValue(
|
||||
jsonValue: String,
|
||||
namecoinName: String,
|
||||
): NamecoinNostrResult? {
|
||||
val json =
|
||||
kotlinx.serialization.json.Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
}
|
||||
val obj =
|
||||
try {
|
||||
json.parseToJsonElement(jsonValue).jsonObject
|
||||
} catch (_: Exception) {
|
||||
return null
|
||||
}
|
||||
|
||||
val nostrField = obj["nostr"] ?: return null
|
||||
|
||||
if (nostrField is kotlinx.serialization.json.JsonPrimitive && nostrField.isString) {
|
||||
val pubkey = nostrField.content
|
||||
if (pubkey.matches(Regex("^[0-9a-fA-F]{64}$"))) {
|
||||
return NamecoinNostrResult(pubkey = pubkey.lowercase(), namecoinName = namecoinName)
|
||||
}
|
||||
}
|
||||
|
||||
if (nostrField is kotlinx.serialization.json.JsonObject) {
|
||||
val pubkey = (nostrField["pubkey"] as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||
if (pubkey != null && pubkey.matches(Regex("^[0-9a-fA-F]{64}$"))) {
|
||||
val relays =
|
||||
try {
|
||||
nostrField["relays"]?.jsonArray?.mapNotNull {
|
||||
(it as? kotlinx.serialization.json.JsonPrimitive)?.content
|
||||
} ?: emptyList()
|
||||
} catch (_: Exception) {
|
||||
emptyList()
|
||||
}
|
||||
return NamecoinNostrResult(pubkey = pubkey.lowercase(), relays = relays, namecoinName = namecoinName)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Needed for jsonArray and jsonObject extensions
|
||||
private val kotlinx.serialization.json.JsonElement.jsonObject
|
||||
get() = this as kotlinx.serialization.json.JsonObject
|
||||
private val kotlinx.serialization.json.JsonElement.jsonArray
|
||||
get() = this as kotlinx.serialization.json.JsonArray
|
||||
}
|
||||
Reference in New Issue
Block a user