Merge pull request #1914 from mstrofnone/desktop-namecoin-port

Port Namecoin NIP-05 resolution to Desktop app
This commit is contained in:
Vitor Pamplona
2026-05-08 08:55:09 -04:00
committed by GitHub
16 changed files with 1947 additions and 136 deletions
@@ -281,8 +281,16 @@ class NamecoinNameResolver(
* Extract Nostr data from a `d/` domain value.
*
* Supports:
* { "nostr": "hex-pubkey" } → simple form
* { "nostr": "hex-pubkey" } → simple-string form
* { "nostr": { "names": { "alice": "hex" }, ... } } → extended NIP-05-like form
* { "nostr": { "pubkey": "hex", "relays": [...] } } → single-identity form
*
* The single-identity form is the same shape used by `id/` records and is
* the natural way to express "this one name = this one pubkey". It only
* resolves the root local-part (`_`) — there are no sub-identities in
* this shape. If `nostr.names` is also present, it wins for any
* sub-identity; root-lookups fall back to the bare `pubkey` when
* `names["_"]` is missing.
*/
private fun extractFromDomainValue(
value: JsonObject,
@@ -290,7 +298,7 @@ class NamecoinNameResolver(
): NamecoinNostrResult? {
val nostrField = value["nostr"] ?: return null
// Simple form: "nostr": "hex-pubkey"
// Simple-string form: "nostr": "hex-pubkey"
if (nostrField is JsonPrimitive && nostrField.isString) {
val pubkey = nostrField.content
if (parsed.localPart == "_" && isValidPubkey(pubkey)) {
@@ -305,48 +313,73 @@ class NamecoinNameResolver(
if (parsed.localPart != "_") return null
}
// Extended form: "nostr": { "names": {...}, "relays": {...} }
if (nostrField is JsonObject) {
val names = nostrField["names"]?.jsonObject ?: return null
// Extended NIP-05-like form first: "nostr": { "names": {...}, "relays": {...} }
val names = nostrField["names"]?.jsonObject
// Resolve: exact match → "_" root → first entry (root lookups only)
val resolvedLocalPart: String
val pubkey: String
if (names != null) {
val exactMatch = names[parsed.localPart]
val rootMatch = names["_"]
val firstEntry = if (parsed.localPart == "_") names.entries.firstOrNull() else null
val exactMatch = names[parsed.localPart]
val rootMatch = names["_"]
val firstEntry = if (parsed.localPart == "_") names.entries.firstOrNull() else null
when {
exactMatch is JsonPrimitive && isValidPubkey(exactMatch.content) -> {
resolvedLocalPart = parsed.localPart
pubkey = exactMatch.content
if (exactMatch is JsonPrimitive && isValidPubkey(exactMatch.content)) {
return NamecoinNostrResult(
pubkey = exactMatch.content.lowercase(),
relays = extractRelays(nostrField, exactMatch.content),
namecoinName = parsed.namecoinName,
localPart = parsed.localPart,
)
}
rootMatch is JsonPrimitive && isValidPubkey(rootMatch.content) -> {
resolvedLocalPart = "_"
pubkey = rootMatch.content
if (rootMatch is JsonPrimitive && isValidPubkey(rootMatch.content)) {
return NamecoinNostrResult(
pubkey = rootMatch.content.lowercase(),
relays = extractRelays(nostrField, rootMatch.content),
namecoinName = parsed.namecoinName,
localPart = "_",
)
}
firstEntry != null &&
if (firstEntry != null &&
firstEntry.value is JsonPrimitive &&
isValidPubkey((firstEntry.value as JsonPrimitive).content) -> {
resolvedLocalPart = firstEntry.key
pubkey = (firstEntry.value as JsonPrimitive).content
}
else -> {
return null
isValidPubkey((firstEntry.value as JsonPrimitive).content)
) {
val pk = (firstEntry.value as JsonPrimitive).content
return NamecoinNostrResult(
pubkey = pk.lowercase(),
relays = extractRelays(nostrField, pk),
namecoinName = parsed.namecoinName,
localPart = firstEntry.key,
)
}
// names was present but didn't yield a match. Fall through to
// the single-identity check below — only meaningful for root
// lookups (non-root requests against a names-only record
// correctly stop here).
if (parsed.localPart != "_") return null
}
val relays = extractRelays(nostrField, pubkey)
return NamecoinNostrResult(
pubkey = pubkey.lowercase(),
relays = relays,
namecoinName = parsed.namecoinName,
localPart = resolvedLocalPart,
)
// Single-identity form: "nostr": { "pubkey": "hex", "relays": [...] }
// Only resolves the root local-part; non-root requests against a
// single-identity record fall through to null so we don't hand
// alice@example.bit the root operator's identity.
if (parsed.localPart == "_") {
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,
localPart = "_",
)
}
}
}
return null
@@ -457,7 +457,8 @@ class ElectrumXClient(
/**
* Parse a Namecoin name and value from a verbose transaction response.
*
* Scans each output for a NAME_UPDATE script (starts with OP_3 = 0x53),
* Scans each output for a NAME_UPDATE (OP_3 = 0x53) or NAME_FIRSTUPDATE
* (OP_2 = 0x52) script,
* then extracts the name and value from the script's push data.
*/
private fun parseNameFromTransaction(
@@ -482,8 +483,10 @@ class ElectrumXClient(
?.content
?: continue
// NAME_UPDATE scripts start with OP_3 (0x53)
if (!scriptHex.startsWith("53")) continue
// NAME_UPDATE scripts start with OP_3 (0x53);
// NAME_FIRSTUPDATE scripts start with OP_2 (0x52). Both carry the
// current value at the time of that on-chain operation.
if (!scriptHex.startsWith("53") && !scriptHex.startsWith("52")) continue
val scriptBytes = hexToBytes(scriptHex)
val parsed = parseNameScript(scriptBytes) ?: continue
@@ -510,7 +513,9 @@ class ElectrumXClient(
* @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
if (script.isEmpty()) return null
val op = script[0]
if (op != OP_NAME_UPDATE && op != OP_NAME_FIRSTUPDATE) return null
var pos = 1
@@ -518,6 +523,12 @@ class ElectrumXClient(
val (nameBytes, newPos1) = readPushData(script, pos) ?: return null
pos = newPos1
// FIRSTUPDATE has an extra <rand> push between name and value; skip it.
if (op == OP_NAME_FIRSTUPDATE) {
val (_, newPos2) = readPushData(script, pos) ?: return null
pos = newPos2
}
// Read value
val (valueBytes, _) = readPushData(script, pos) ?: return null
@@ -815,6 +826,7 @@ class ElectrumXClient(
const val NAME_EXPIRE_DEPTH = 36_000
// Namecoin script opcodes
private const val OP_NAME_FIRSTUPDATE: Byte = 0x52 // OP_2 repurposed by Namecoin
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