feat(namecoin): resolve import items per ifa-0001 (NIP-05 only)
Adds a new `NamecoinImportResolver` that follows the `import` field of a
Namecoin Domain Name Object value, recursively merging the imported
values into the importing object before `NamecoinNameResolver` reads
the `nostr` field.
Why
---
The 520-byte per-name limit on Namecoin is tight when a single record
needs `ip`, NIP-05 names, TLS, and per-subdomain map entries. A common
workaround (used by `testls.bit` and others) is to keep the apex record
small by delegating shared blocks into a sibling name via
`"import":"dd/<name>"` per ifa-0001 §"import". Without import support,
NIP-05 search for `testls.bit` (and the like) fails: the resolver sees
no `nostr` field at `d/testls` and gives up before consulting the
imported `dd/testls` that actually carries the names block.
What
----
Per ifa-0001 §"import":
* Importer items take precedence over imported items (including `null`,
which suppresses the imported counterpart).
* Recursion is supported up to 4 levels (the spec minimum) by default.
Cycles are broken by a visited-set; over-budget chains are silently
truncated, with the importer's own items still taking effect.
* Multiple imports in one array merge later-wins (the array's own
order), with the importing object on top of all of them.
* Subdomain selectors (the optional 2nd element of each inner array)
are resolved through a `map` walk inside the imported value, with
the standard exact-label > `*` wildcard > `""` default rules.
* Three short-hand value forms are accepted alongside the canonical
array-of-arrays: `"import":"d/foo"`, `"import":["d/foo"]`, and
`"import":["d/foo","selector"]`. They map onto the canonical
representation losslessly.
* A failed import lookup (name not found, malformed JSON, network
error) is treated as `{}` rather than failing the whole record, so
transient ElectrumX hiccups don't kill resolution. The importing
object's own items still apply.
Wires the expander into `NamecoinNameResolver.performLookup` and
`performLookupDetailed`. Records without an `import` key skip the
resolver entirely (zero extra I/O), so non-import names pay no cost.
Tests
-----
20 new tests in `NamecoinImportTest`:
* Unit (resolver in isolation): no-import passthrough, all four
shorthand value forms, importer precedence, null suppression,
depth-4 recursion happy path, over-budget truncation, lookup
failure, malformed JSON, malformed `import` value, cycle
protection, multi-label selector descending in DNS order, wildcard
fallback.
* Integration with `NamecoinNameResolver`: bare and named NIP-05 across
an import (mirroring the real-world `testls.bit -> dd/testls`
deployment); regression guard that no-import records issue exactly
one ElectrumX query; importer-wins on `nostr.names`; lenient
failure when the imported boilerplate is unreachable;
`resolveDetailed` returns `Success` and `NoNostrField` correctly
across imports.
All 15 existing `NamecoinNameResolverTest` cases continue to pass.
Scope note
----------
This PR is intentionally scoped to the NIP-05 / search path. The
relay-resolution path (#2595) also benefits from import support; that
wiring is left to land alongside #2595 so the two PRs can move
independently.
This commit is contained in:
+276
@@ -0,0 +1,276 @@
|
|||||||
|
/*
|
||||||
|
* 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.nip05DnsIdentifiers.namecoin
|
||||||
|
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves the [`import`][ifa-0001] item of a Namecoin Domain Name Object,
|
||||||
|
* recursively merging values from the imported names into the importing
|
||||||
|
* object before the caller extracts fields like `nostr`.
|
||||||
|
*
|
||||||
|
* Per [ifa-0001](https://github.com/namecoin/proposals/blob/master/ifa-0001.md)
|
||||||
|
* §"import":
|
||||||
|
*
|
||||||
|
* - The importing object's items take precedence over the imported items.
|
||||||
|
* `null` items in the importer are still considered "present" and so
|
||||||
|
* nullify the corresponding imported item (semantic suppression).
|
||||||
|
* - The `"import"` value is an array of arrays. Each inner array has at
|
||||||
|
* least one element, the name to import (e.g. `"d/example2"`), and an
|
||||||
|
* optional second element, a Subdomain Selector (DNS-format, dotted).
|
||||||
|
* Selector labels are resolved via the imported value's `map` tree
|
||||||
|
* before merging.
|
||||||
|
* - Three short-hand value forms are accepted alongside the canonical
|
||||||
|
* array-of-arrays. Real-world Namecoin records frequently write the
|
||||||
|
* bare-string form, so we MUST handle it:
|
||||||
|
* - `"import": "d/foo"` ↔ `[["d/foo"]]`
|
||||||
|
* - `"import": ["d/foo"]` ↔ `[["d/foo"]]`
|
||||||
|
* - `"import": ["d/foo","sub"]` ↔ `[["d/foo","sub"]]`
|
||||||
|
* - Recursion: the spec mandates that implementations support **at least
|
||||||
|
* a recursion depth of four**. We default to that limit; deeper chains
|
||||||
|
* are silently truncated (the importing object's own items still apply).
|
||||||
|
* - Cycles are broken by a visited-set keyed on `name|selector`.
|
||||||
|
* - Maps are merged shallow-per-spec: a key in the importing object
|
||||||
|
* replaces the imported value for that key wholesale; there is no
|
||||||
|
* recursive merge of nested objects (none of the items consumed by
|
||||||
|
* Quartz today require it).
|
||||||
|
* - A failed lookup (name not found, malformed JSON, network error)
|
||||||
|
* MAY cause the whole importing record to fail per spec. To preserve
|
||||||
|
* Quartz's existing best-effort namecoin behaviour (where transient
|
||||||
|
* ElectrumX hiccups don't kill resolution outright), this
|
||||||
|
* implementation treats a failed import as if the imported value
|
||||||
|
* were the empty object `{}`. The importing object's own items still
|
||||||
|
* apply.
|
||||||
|
*
|
||||||
|
* Use [expandImports] in any flow that has a parsed root [JsonObject]
|
||||||
|
* and needs the post-import view before extracting record-specific fields.
|
||||||
|
*/
|
||||||
|
internal object NamecoinImportResolver {
|
||||||
|
/** The minimum recursion depth ifa-0001 requires implementations to support. */
|
||||||
|
const val DEFAULT_MAX_DEPTH: Int = 4
|
||||||
|
|
||||||
|
private val SHARED_JSON =
|
||||||
|
Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Async name lookup callback. Returns the raw value JSON string of the
|
||||||
|
* named record, or `null` if the name does not exist / is expired /
|
||||||
|
* could not be fetched. Failures are absorbed: the returned object is
|
||||||
|
* always usable.
|
||||||
|
*
|
||||||
|
* The callback is called once per `import` target (deduplicated within
|
||||||
|
* a single recursive expansion path).
|
||||||
|
*/
|
||||||
|
typealias NameValueFetcher = suspend (namecoinName: String) -> String?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand all `import` items in [root] (and recursively in imported
|
||||||
|
* objects) up to [maxDepth] levels deep, returning a single merged
|
||||||
|
* [JsonObject] with no `import` key.
|
||||||
|
*
|
||||||
|
* The merged object preserves the importing object's items unchanged;
|
||||||
|
* imported items only fill in keys the importing object did not declare
|
||||||
|
* (including keys whose value is `null` — those remain suppressed).
|
||||||
|
*
|
||||||
|
* If [root] has no `import` key, it is returned unchanged.
|
||||||
|
*/
|
||||||
|
suspend fun expandImports(
|
||||||
|
root: JsonObject,
|
||||||
|
maxDepth: Int = DEFAULT_MAX_DEPTH,
|
||||||
|
fetcher: NameValueFetcher,
|
||||||
|
): JsonObject = expandRecursive(root, fetcher, maxDepth, mutableSetOf())
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private suspend fun expandRecursive(
|
||||||
|
obj: JsonObject,
|
||||||
|
fetcher: NameValueFetcher,
|
||||||
|
budgetRemaining: Int,
|
||||||
|
visited: MutableSet<String>,
|
||||||
|
): JsonObject {
|
||||||
|
val importItem = obj["import"] ?: return obj
|
||||||
|
val operations = parseImportItem(importItem) ?: return removeImportKey(obj)
|
||||||
|
if (operations.isEmpty() || budgetRemaining <= 0) return removeImportKey(obj)
|
||||||
|
|
||||||
|
// Walk imports left-to-right. Spec is silent on multiple-import
|
||||||
|
// precedence; we follow the common-sense rule that LATER imports
|
||||||
|
// override EARLIER ones in the same array (otherwise listing two
|
||||||
|
// libraries would silently ignore the second). The whole accumulator
|
||||||
|
// still loses to the importing object on top of all of it.
|
||||||
|
var accumulator: JsonObject = JsonObject(emptyMap())
|
||||||
|
for (op in operations) {
|
||||||
|
val visitKey = visitKeyFor(op)
|
||||||
|
if (!visited.add(visitKey)) continue // cycle / duplicate within this chain
|
||||||
|
try {
|
||||||
|
val importedRaw = fetcher(op.name) ?: continue
|
||||||
|
val importedRoot = tryParseObject(importedRaw) ?: continue
|
||||||
|
val selectorView = applySelector(importedRoot, op.selector) ?: continue
|
||||||
|
val expanded =
|
||||||
|
expandRecursive(
|
||||||
|
selectorView,
|
||||||
|
fetcher,
|
||||||
|
budgetRemaining - 1,
|
||||||
|
visited,
|
||||||
|
)
|
||||||
|
accumulator = mergeImporterWins(importer = expanded, imported = accumulator)
|
||||||
|
} finally {
|
||||||
|
visited.remove(visitKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally merge the importing object on top, removing its `import` key.
|
||||||
|
val withoutImport = removeImportKey(obj)
|
||||||
|
return mergeImporterWins(importer = withoutImport, imported = accumulator)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge two objects with importer-wins semantics: every key in [importer]
|
||||||
|
* stays as-is (including `null` values, which suppress the imported
|
||||||
|
* counterpart per ifa-0001); keys present only in [imported] are added.
|
||||||
|
*/
|
||||||
|
private fun mergeImporterWins(
|
||||||
|
importer: JsonObject,
|
||||||
|
imported: JsonObject,
|
||||||
|
): JsonObject {
|
||||||
|
if (imported.isEmpty()) return importer
|
||||||
|
if (importer.isEmpty()) return imported
|
||||||
|
val out = LinkedHashMap<String, JsonElement>(importer.size + imported.size)
|
||||||
|
// Imported first so we can overwrite with importer.
|
||||||
|
for ((k, v) in imported) out[k] = v
|
||||||
|
for ((k, v) in importer) out[k] = v
|
||||||
|
return JsonObject(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk the imported object's `map` tree to the node addressed by
|
||||||
|
* [selector] (DNS dotted, e.g. `relay`, `a.b.c`). Empty selector
|
||||||
|
* returns [root] unchanged.
|
||||||
|
*
|
||||||
|
* Resolution rules per ifa-0001 §"map":
|
||||||
|
* - Exact label match wins.
|
||||||
|
* - Wildcard `*` matches any single label.
|
||||||
|
* - Empty key `""` is the default for the current level when no other
|
||||||
|
* match applies.
|
||||||
|
* - A non-object child terminates the walk with `null`.
|
||||||
|
*/
|
||||||
|
private fun applySelector(
|
||||||
|
root: JsonObject,
|
||||||
|
selector: String,
|
||||||
|
): JsonObject? {
|
||||||
|
if (selector.isEmpty()) return root
|
||||||
|
// Selector is DNS-dotted: leftmost label is the most-specific. The
|
||||||
|
// `map` tree is rooted at the parent and nests inwards toward the
|
||||||
|
// leaf, so we walk labels right-to-left (the rightmost label is
|
||||||
|
// the immediate child of the parent's `map`).
|
||||||
|
val labels =
|
||||||
|
selector
|
||||||
|
.split('.')
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.asReversed()
|
||||||
|
if (labels.isEmpty()) return root
|
||||||
|
|
||||||
|
var current: JsonObject = root
|
||||||
|
for (label in labels) {
|
||||||
|
val map = current["map"] as? JsonObject ?: return null
|
||||||
|
val child =
|
||||||
|
(map[label] as? JsonObject)
|
||||||
|
?: (map["*"] as? JsonObject)
|
||||||
|
?: (map[""] as? JsonObject)
|
||||||
|
?: return null
|
||||||
|
current = child
|
||||||
|
}
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryParseObject(rawJson: String): JsonObject? = runCatching { SHARED_JSON.parseToJsonElement(rawJson).jsonObject }.getOrNull()
|
||||||
|
|
||||||
|
private fun removeImportKey(obj: JsonObject): JsonObject {
|
||||||
|
if (!obj.containsKey("import")) return obj
|
||||||
|
val out = LinkedHashMap<String, JsonElement>(obj.size - 1)
|
||||||
|
for ((k, v) in obj) if (k != "import") out[k] = v
|
||||||
|
return JsonObject(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun visitKeyFor(op: ImportOp): String = "${op.name}|${op.selector}"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse the value of an `import` item into a flat list of [ImportOp]
|
||||||
|
* descriptors. Returns `null` if the value is malformed.
|
||||||
|
*
|
||||||
|
* Accepted shapes (in order of preference):
|
||||||
|
* - canonical: `[ ["d/foo"], ["d/bar","sub"] ]`
|
||||||
|
* - shorthand string: `"d/foo"` → one op with no selector
|
||||||
|
* - shorthand single-array: `["d/foo"]` → one op with no selector
|
||||||
|
* - shorthand pair-array: `["d/foo","sub"]` → one op with selector
|
||||||
|
*
|
||||||
|
* Anything else is treated as malformed and the import is skipped.
|
||||||
|
*/
|
||||||
|
private fun parseImportItem(item: JsonElement): List<ImportOp>? {
|
||||||
|
// Shorthand: bare string.
|
||||||
|
if (item is JsonPrimitive && item.isString) {
|
||||||
|
return listOf(ImportOp(item.content, ""))
|
||||||
|
}
|
||||||
|
// Array shapes.
|
||||||
|
val arr = (item as? JsonArray) ?: return null
|
||||||
|
if (arr.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
// Distinguish: array-of-arrays (canonical) vs array-of-strings (shorthand).
|
||||||
|
val firstIsArray = arr.first() is JsonArray
|
||||||
|
if (firstIsArray) {
|
||||||
|
return arr.mapNotNull { entry ->
|
||||||
|
val inner = (entry as? JsonArray) ?: return@mapNotNull null
|
||||||
|
opFromArray(inner)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Shorthand: ["name"] or ["name","selector"]. All elements must be
|
||||||
|
// strings; anything else makes the whole item malformed.
|
||||||
|
return listOfNotNull(opFromArray(arr))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun opFromArray(arr: JsonArray): ImportOp? {
|
||||||
|
if (arr.isEmpty()) return null
|
||||||
|
val name = (arr[0] as? JsonPrimitive)?.takeIf { it.isString }?.content?.trim() ?: return null
|
||||||
|
if (name.isEmpty()) return null
|
||||||
|
val selector =
|
||||||
|
if (arr.size >= 2) {
|
||||||
|
(arr[1] as? JsonPrimitive)?.takeIf { it.isString }?.content?.trim() ?: ""
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
// Trailing dot is forbidden by spec; treat as malformed → no selector.
|
||||||
|
if (selector.endsWith('.')) return null
|
||||||
|
return ImportOp(name = name, selector = selector)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class ImportOp(
|
||||||
|
val name: String,
|
||||||
|
/** DNS dotted, may be empty. Preserved as written. */
|
||||||
|
val selector: String,
|
||||||
|
)
|
||||||
|
}
|
||||||
+31
-4
@@ -210,10 +210,11 @@ class NamecoinNameResolver(
|
|||||||
private suspend fun performLookup(parsed: ParsedIdentifier): NamecoinNostrResult? {
|
private suspend fun performLookup(parsed: ParsedIdentifier): NamecoinNostrResult? {
|
||||||
val nameResult = electrumxClient.nameShowWithFallback(parsed.namecoinName, serverListProvider()) ?: return null
|
val nameResult = electrumxClient.nameShowWithFallback(parsed.namecoinName, serverListProvider()) ?: return null
|
||||||
val valueJson = tryParseJson(nameResult.value) ?: return null
|
val valueJson = tryParseJson(nameResult.value) ?: return null
|
||||||
|
val merged = expandImportsIfPresent(valueJson)
|
||||||
|
|
||||||
return when (parsed.namespace) {
|
return when (parsed.namespace) {
|
||||||
Namespace.DOMAIN -> extractFromDomainValue(valueJson, parsed)
|
Namespace.DOMAIN -> extractFromDomainValue(merged, parsed)
|
||||||
Namespace.IDENTITY -> extractFromIdentityValue(valueJson, parsed)
|
Namespace.IDENTITY -> extractFromIdentityValue(merged, parsed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -236,11 +237,12 @@ class NamecoinNameResolver(
|
|||||||
val valueJson =
|
val valueJson =
|
||||||
tryParseJson(nameResult.value)
|
tryParseJson(nameResult.value)
|
||||||
?: return NamecoinResolveOutcome.NoNostrField(parsed.namecoinName)
|
?: return NamecoinResolveOutcome.NoNostrField(parsed.namecoinName)
|
||||||
|
val merged = expandImportsIfPresent(valueJson)
|
||||||
|
|
||||||
val nostrResult =
|
val nostrResult =
|
||||||
when (parsed.namespace) {
|
when (parsed.namespace) {
|
||||||
Namespace.DOMAIN -> extractFromDomainValue(valueJson, parsed)
|
Namespace.DOMAIN -> extractFromDomainValue(merged, parsed)
|
||||||
Namespace.IDENTITY -> extractFromIdentityValue(valueJson, parsed)
|
Namespace.IDENTITY -> extractFromIdentityValue(merged, parsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
return if (nostrResult != null) {
|
return if (nostrResult != null) {
|
||||||
@@ -250,6 +252,31 @@ class NamecoinNameResolver(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand any ifa-0001 `import` items in [root] into a single merged
|
||||||
|
* object, fetching imported names through this resolver's ElectrumX
|
||||||
|
* client. Records without an `import` key are returned unchanged with
|
||||||
|
* zero extra I/O.
|
||||||
|
*
|
||||||
|
* Failures (name not found, malformed JSON, network errors) are
|
||||||
|
* absorbed: the corresponding import contributes nothing and the
|
||||||
|
* importing record's own items still apply. This keeps resolution
|
||||||
|
* best-effort, in line with the rest of the namecoin path.
|
||||||
|
*/
|
||||||
|
private suspend fun expandImportsIfPresent(root: JsonObject): JsonObject {
|
||||||
|
if (!root.containsKey("import")) return root
|
||||||
|
return NamecoinImportResolver.expandImports(root) { name ->
|
||||||
|
try {
|
||||||
|
electrumxClient.nameShowWithFallback(name, serverListProvider())?.value
|
||||||
|
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||||
|
throw e
|
||||||
|
} catch (e: NamecoinLookupException) {
|
||||||
|
// Best-effort: missing/expired/unreachable → contribute nothing.
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract Nostr data from a `d/` domain value.
|
* Extract Nostr data from a `d/` domain value.
|
||||||
*
|
*
|
||||||
|
|||||||
+533
@@ -0,0 +1,533 @@
|
|||||||
|
/*
|
||||||
|
* 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 com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.ElectrumxServer
|
||||||
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.IElectrumXClient
|
||||||
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NameShowResult
|
||||||
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinImportResolver
|
||||||
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinNameResolver
|
||||||
|
import com.vitorpamplona.quartz.nip05DnsIdentifiers.namecoin.NamecoinResolveOutcome
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
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
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hermetic tests for [NamecoinImportResolver] and the import-aware
|
||||||
|
* NIP-05 resolution path through [NamecoinNameResolver].
|
||||||
|
*
|
||||||
|
* Tests do NOT touch the network: every "imported" name is served by
|
||||||
|
* an in-memory map keyed by Namecoin name.
|
||||||
|
*/
|
||||||
|
class NamecoinImportTest {
|
||||||
|
private val json =
|
||||||
|
Json {
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
isLenient = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Pure unit tests (NamecoinImportResolver only) ─────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `no import key returns object unchanged`() =
|
||||||
|
runTest {
|
||||||
|
val obj = parse("""{"ip":"1.2.3.4"}""")
|
||||||
|
val expanded = NamecoinImportResolver.expandImports(obj) { error("should not be called") }
|
||||||
|
assertEquals(obj, expanded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `string shorthand import merges imported items into importer`() =
|
||||||
|
runTest {
|
||||||
|
// ifa-0001 §"import" canonical form is array-of-arrays, but the
|
||||||
|
// string form `"import": "d/foo"` is widely used in practice; we
|
||||||
|
// accept it as shorthand for `[["d/foo"]]`.
|
||||||
|
val obj = parse("""{"import":"d/lib","ip":"1.1.1.1"}""")
|
||||||
|
val expanded =
|
||||||
|
NamecoinImportResolver.expandImports(obj) { name ->
|
||||||
|
when (name) {
|
||||||
|
"d/lib" -> """{"ip":"9.9.9.9","nostr":{"names":{"_":"abc"}}}"""
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// importer wins on `ip`, imports fill in `nostr.names`.
|
||||||
|
assertEquals("1.1.1.1", expanded["ip"]?.jsonPrimitive?.content)
|
||||||
|
assertEquals(
|
||||||
|
"abc",
|
||||||
|
expanded["nostr"]
|
||||||
|
?.jsonObject
|
||||||
|
?.get("names")
|
||||||
|
?.jsonObject
|
||||||
|
?.get("_")
|
||||||
|
?.jsonPrimitive
|
||||||
|
?.content,
|
||||||
|
)
|
||||||
|
assertFalse("import key must not survive expansion", expanded.containsKey("import"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `canonical array of arrays form processes each in order`() =
|
||||||
|
runTest {
|
||||||
|
val obj = parse("""{"import":[["d/a"],["d/b"]]}""")
|
||||||
|
val expanded =
|
||||||
|
NamecoinImportResolver.expandImports(obj) { name ->
|
||||||
|
when (name) {
|
||||||
|
"d/a" -> """{"ip":"10.0.0.1","tag":"from-a"}"""
|
||||||
|
"d/b" -> """{"ip":"10.0.0.2","extra":"from-b"}"""
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// d/b is processed AFTER d/a, so its `ip` (10.0.0.2) overrides d/a's.
|
||||||
|
// The importer itself has no `ip`, so the last imported one wins.
|
||||||
|
assertEquals("10.0.0.2", expanded["ip"]?.jsonPrimitive?.content)
|
||||||
|
assertEquals("from-a", expanded["tag"]?.jsonPrimitive?.content)
|
||||||
|
assertEquals("from-b", expanded["extra"]?.jsonPrimitive?.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `pair-array shorthand uses subdomain selector`() =
|
||||||
|
runTest {
|
||||||
|
val obj = parse("""{"import":["d/lib","relay"]}""")
|
||||||
|
val expanded =
|
||||||
|
NamecoinImportResolver.expandImports(obj) { name ->
|
||||||
|
when (name) {
|
||||||
|
"d/lib" -> {
|
||||||
|
"""
|
||||||
|
{"ip":"1.1.1.1",
|
||||||
|
"map":{"relay":{"ip":"7.7.7.7","tag":"selected"}}}
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// We selected `map.relay` from d/lib, so its contents (ip=7.7.7.7,
|
||||||
|
// tag=selected) are merged at the top level of the importer.
|
||||||
|
// d/lib's top-level ip (1.1.1.1) is NOT seen because we descended.
|
||||||
|
assertEquals("7.7.7.7", expanded["ip"]?.jsonPrimitive?.content)
|
||||||
|
assertEquals("selected", expanded["tag"]?.jsonPrimitive?.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `importer items take precedence over imported items`() =
|
||||||
|
runTest {
|
||||||
|
val obj = parse("""{"import":"d/lib","ip":"1.1.1.1","extra":"local"}""")
|
||||||
|
val expanded =
|
||||||
|
NamecoinImportResolver.expandImports(obj) { name ->
|
||||||
|
when (name) {
|
||||||
|
"d/lib" -> """{"ip":"9.9.9.9","extra":"remote","only-imported":"yes"}"""
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals("1.1.1.1", expanded["ip"]?.jsonPrimitive?.content)
|
||||||
|
assertEquals("local", expanded["extra"]?.jsonPrimitive?.content)
|
||||||
|
assertEquals("yes", expanded["only-imported"]?.jsonPrimitive?.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `null in importer suppresses imported value`() =
|
||||||
|
runTest {
|
||||||
|
// ifa-0001: null is "present for precedence" — semantic
|
||||||
|
// suppression. The importer says ip=null, so imported ip is gone.
|
||||||
|
val obj = parse("""{"import":"d/lib","ip":null}""")
|
||||||
|
val expanded =
|
||||||
|
NamecoinImportResolver.expandImports(obj) { name ->
|
||||||
|
when (name) {
|
||||||
|
"d/lib" -> """{"ip":"9.9.9.9","other":"keep"}"""
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The merged object still has `ip` as a JsonNull, not removed.
|
||||||
|
// Downstream parsers ignore null as if absent (same outcome).
|
||||||
|
assertTrue(expanded.containsKey("ip"))
|
||||||
|
assertEquals(
|
||||||
|
kotlinx.serialization.json.JsonNull,
|
||||||
|
expanded["ip"],
|
||||||
|
)
|
||||||
|
assertEquals("keep", expanded["other"]?.jsonPrimitive?.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `recursion depth four is supported`() =
|
||||||
|
runTest {
|
||||||
|
// ifa-0001 mandates implementations support a recursion degree
|
||||||
|
// of at least 4. Test pins the 4-deep happy path.
|
||||||
|
val obj = parse("""{"import":"d/a"}""")
|
||||||
|
val expanded =
|
||||||
|
NamecoinImportResolver.expandImports(obj) { name ->
|
||||||
|
when (name) {
|
||||||
|
"d/a" -> """{"import":"d/b","layer":"a"}"""
|
||||||
|
"d/b" -> """{"import":"d/c","layer":"b"}"""
|
||||||
|
"d/c" -> """{"import":"d/d","layer":"c"}"""
|
||||||
|
"d/d" -> """{"layer":"d","deep":"reached"}"""
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Each layer overrides "layer" so the importer sees "a".
|
||||||
|
// "deep" only exists on d/d and survives to the top.
|
||||||
|
assertEquals("a", expanded["layer"]?.jsonPrimitive?.content)
|
||||||
|
assertEquals("reached", expanded["deep"]?.jsonPrimitive?.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `recursion deeper than max-depth is silently truncated`() =
|
||||||
|
runTest {
|
||||||
|
// Anything past the depth limit is dropped, but the importing
|
||||||
|
// record's own items still apply.
|
||||||
|
val obj = parse("""{"import":"d/a","local":"keep"}""")
|
||||||
|
val expanded =
|
||||||
|
NamecoinImportResolver.expandImports(
|
||||||
|
root = obj,
|
||||||
|
maxDepth = 1, // only one level of imports
|
||||||
|
) { name ->
|
||||||
|
when (name) {
|
||||||
|
"d/a" -> """{"import":"d/b","tag":"from-a"}"""
|
||||||
|
"d/b" -> """{"tag":"from-b","leaf":"won't-show"}"""
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals("from-a", expanded["tag"]?.jsonPrimitive?.content)
|
||||||
|
assertEquals("keep", expanded["local"]?.jsonPrimitive?.content)
|
||||||
|
// d/b was never expanded so its keys are NOT present.
|
||||||
|
assertNull(expanded["leaf"])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `failed import lookup is treated as empty object`() =
|
||||||
|
runTest {
|
||||||
|
// Per our docs: spec says a failed import MAY fail the whole
|
||||||
|
// record; we choose the more lenient "empty object" semantics
|
||||||
|
// so transient ElectrumX hiccups don't kill resolution.
|
||||||
|
val obj = parse("""{"import":"d/missing","local":"survives"}""")
|
||||||
|
val expanded =
|
||||||
|
NamecoinImportResolver.expandImports(obj) { _ ->
|
||||||
|
null // simulate name not found
|
||||||
|
}
|
||||||
|
assertEquals("survives", expanded["local"]?.jsonPrimitive?.content)
|
||||||
|
assertFalse(expanded.containsKey("import"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `cycle in imports is broken without infinite recursion`() =
|
||||||
|
runTest {
|
||||||
|
// d/a imports d/b which imports d/a. A naive resolver would
|
||||||
|
// hang. The visited-set guard breaks the loop on the second
|
||||||
|
// appearance of d/a; importer's own items still apply.
|
||||||
|
val obj = parse("""{"import":"d/a","local":"top"}""")
|
||||||
|
val expanded =
|
||||||
|
NamecoinImportResolver.expandImports(obj) { name ->
|
||||||
|
when (name) {
|
||||||
|
"d/a" -> """{"import":"d/b","fromA":"yes"}"""
|
||||||
|
"d/b" -> """{"import":"d/a","fromB":"yes"}"""
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals("top", expanded["local"]?.jsonPrimitive?.content)
|
||||||
|
// At least one of fromA/fromB should have made it through —
|
||||||
|
// we don't pin which because the cycle break point is an
|
||||||
|
// implementation detail, but the call MUST terminate.
|
||||||
|
assertTrue(
|
||||||
|
expanded.containsKey("fromA") || expanded.containsKey("fromB"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `malformed import value is skipped without throwing`() =
|
||||||
|
runTest {
|
||||||
|
val obj = parse("""{"import":42,"local":"keep"}""")
|
||||||
|
val expanded = NamecoinImportResolver.expandImports(obj) { _ -> null }
|
||||||
|
assertEquals("keep", expanded["local"]?.jsonPrimitive?.content)
|
||||||
|
assertFalse(expanded.containsKey("import"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `import target with malformed JSON is skipped`() =
|
||||||
|
runTest {
|
||||||
|
val obj = parse("""{"import":"d/broken","local":"keep"}""")
|
||||||
|
val expanded =
|
||||||
|
NamecoinImportResolver.expandImports(obj) { name ->
|
||||||
|
when (name) {
|
||||||
|
"d/broken" -> """not valid json {{{"""
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals("keep", expanded["local"]?.jsonPrimitive?.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selector with multiple labels descends in DNS order`() =
|
||||||
|
runTest {
|
||||||
|
// selector "a.b" means: descend map.b, then map.a (DNS-rightmost
|
||||||
|
// first). The empty-key and "*" wildcard rules apply too.
|
||||||
|
val obj = parse("""{"import":[["d/lib","a.b"]]}""")
|
||||||
|
val expanded =
|
||||||
|
NamecoinImportResolver.expandImports(obj) { name ->
|
||||||
|
when (name) {
|
||||||
|
"d/lib" -> {
|
||||||
|
"""
|
||||||
|
{"map":{"b":{"map":{"a":{"value":"deep"}}}}}
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals("deep", expanded["value"]?.jsonPrimitive?.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selector falls back to wildcard star when exact label is missing`() =
|
||||||
|
runTest {
|
||||||
|
val obj = parse("""{"import":["d/lib","ghost"]}""")
|
||||||
|
val expanded =
|
||||||
|
NamecoinImportResolver.expandImports(obj) { name ->
|
||||||
|
when (name) {
|
||||||
|
"d/lib" -> """{"map":{"*":{"value":"wildcard"}}}"""
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assertEquals("wildcard", expanded["value"]?.jsonPrimitive?.content)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Integration: NamecoinNameResolver (NIP-05) follows import ──────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `NIP-05 lookup follows import for shared nostr names block`() =
|
||||||
|
runTest {
|
||||||
|
// The real-world `testls.bit` deployment: the apex record at
|
||||||
|
// `d/testls` is up against the 520-byte per-name limit and
|
||||||
|
// delegates its `nostr.names` block to a sibling name via
|
||||||
|
// `"import":"dd/testls"`. Without import support, NIP-05
|
||||||
|
// resolution sees no `nostr` field at d/testls and fails.
|
||||||
|
val client =
|
||||||
|
FakeElectrumXClient().apply {
|
||||||
|
register(
|
||||||
|
"d/testls",
|
||||||
|
"""{"import":"dd/testls","ip":"107.152.38.155"}""",
|
||||||
|
)
|
||||||
|
register(
|
||||||
|
"dd/testls",
|
||||||
|
"""
|
||||||
|
{"nostr":{"names":{
|
||||||
|
"_":"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
|
||||||
|
"m":"6cdebccabda1dfa058ab85352a79509b592b2bdfa0370325e28ec1cb4f18667d"
|
||||||
|
}}}
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val resolver = NamecoinNameResolver(client, lookupTimeoutMs = 1_000L)
|
||||||
|
|
||||||
|
// Bare `.bit` resolves to root entry.
|
||||||
|
val rootResult = resolver.resolve("testls.bit")
|
||||||
|
assertNotNull("bare testls.bit should resolve via import", rootResult)
|
||||||
|
assertEquals(
|
||||||
|
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
|
||||||
|
rootResult?.pubkey,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Named identity `m@testls.bit` resolves through the same import.
|
||||||
|
val mResult = resolver.resolve("m@testls.bit")
|
||||||
|
assertNotNull("m@testls.bit should resolve via import", mResult)
|
||||||
|
assertEquals(
|
||||||
|
"6cdebccabda1dfa058ab85352a79509b592b2bdfa0370325e28ec1cb4f18667d",
|
||||||
|
mResult?.pubkey,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Both names must have been queried (parent + import target).
|
||||||
|
assertTrue("d/testls" in client.queriedNames)
|
||||||
|
assertTrue("dd/testls" in client.queriedNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolveDetailed returns Success when import supplies the names block`() =
|
||||||
|
runTest {
|
||||||
|
val client =
|
||||||
|
FakeElectrumXClient().apply {
|
||||||
|
register(
|
||||||
|
"d/testls",
|
||||||
|
"""{"import":"dd/testls"}""",
|
||||||
|
)
|
||||||
|
register(
|
||||||
|
"dd/testls",
|
||||||
|
"""
|
||||||
|
{"nostr":{"names":{
|
||||||
|
"m":"6cdebccabda1dfa058ab85352a79509b592b2bdfa0370325e28ec1cb4f18667d"
|
||||||
|
}}}
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val resolver = NamecoinNameResolver(client, lookupTimeoutMs = 1_000L)
|
||||||
|
val outcome = resolver.resolveDetailed("m@testls.bit")
|
||||||
|
assertTrue("expected Success, got $outcome", outcome is NamecoinResolveOutcome.Success)
|
||||||
|
outcome as NamecoinResolveOutcome.Success
|
||||||
|
assertEquals(
|
||||||
|
"6cdebccabda1dfa058ab85352a79509b592b2bdfa0370325e28ec1cb4f18667d",
|
||||||
|
outcome.result.pubkey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `resolveDetailed returns NoNostrField when import target lacks nostr`() =
|
||||||
|
runTest {
|
||||||
|
val client =
|
||||||
|
FakeElectrumXClient().apply {
|
||||||
|
register(
|
||||||
|
"d/testls",
|
||||||
|
"""{"import":"dd/testls"}""",
|
||||||
|
)
|
||||||
|
register(
|
||||||
|
"dd/testls",
|
||||||
|
"""{"ip":"1.2.3.4"}""", // no nostr field even after merge
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val resolver = NamecoinNameResolver(client, lookupTimeoutMs = 1_000L)
|
||||||
|
val outcome = resolver.resolveDetailed("testls.bit")
|
||||||
|
assertTrue(
|
||||||
|
"expected NoNostrField, got $outcome",
|
||||||
|
outcome is NamecoinResolveOutcome.NoNostrField,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `record without import key skips import resolver entirely`() =
|
||||||
|
runTest {
|
||||||
|
// Pure regression guard: ensure non-import records pay zero
|
||||||
|
// I/O cost (no extra ElectrumX queries beyond the parent).
|
||||||
|
val client =
|
||||||
|
FakeElectrumXClient().apply {
|
||||||
|
register(
|
||||||
|
"d/plain",
|
||||||
|
"""
|
||||||
|
{"nostr":{"names":{
|
||||||
|
"_":"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"
|
||||||
|
}}}
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val resolver = NamecoinNameResolver(client, lookupTimeoutMs = 1_000L)
|
||||||
|
val result = resolver.resolve("plain.bit")
|
||||||
|
assertNotNull(result)
|
||||||
|
assertEquals(
|
||||||
|
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
|
||||||
|
result?.pubkey,
|
||||||
|
)
|
||||||
|
// Exactly one query: d/plain. No import means no extra fetches.
|
||||||
|
assertEquals(listOf("d/plain"), client.queriedNames)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `importer wins for nostr names so apex can override a named entry`() =
|
||||||
|
runTest {
|
||||||
|
// Importer declares its own `nostr.names.m`; imported value
|
||||||
|
// declares a different one. Importer wins on the whole `nostr`
|
||||||
|
// key (shallow merge per spec).
|
||||||
|
val client =
|
||||||
|
FakeElectrumXClient().apply {
|
||||||
|
register(
|
||||||
|
"d/testls",
|
||||||
|
"""
|
||||||
|
{"import":"dd/testls",
|
||||||
|
"nostr":{"names":{"m":"aaaa000000000000000000000000000000000000000000000000000000000001"}}}
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
register(
|
||||||
|
"dd/testls",
|
||||||
|
"""
|
||||||
|
{"nostr":{"names":{"m":"bbbb000000000000000000000000000000000000000000000000000000000002"}}}
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val resolver = NamecoinNameResolver(client, lookupTimeoutMs = 1_000L)
|
||||||
|
val result = resolver.resolve("m@testls.bit")
|
||||||
|
assertNotNull(result)
|
||||||
|
assertEquals(
|
||||||
|
"aaaa000000000000000000000000000000000000000000000000000000000001",
|
||||||
|
result?.pubkey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `failed import does not break NIP-05 if names are local`() =
|
||||||
|
runTest {
|
||||||
|
// Importer has its own `nostr.names`; the imported
|
||||||
|
// boilerplate happens to be unreachable. Resolution still
|
||||||
|
// succeeds from the importer's own data.
|
||||||
|
val client =
|
||||||
|
FakeElectrumXClient().apply {
|
||||||
|
register(
|
||||||
|
"d/testls",
|
||||||
|
"""
|
||||||
|
{"import":"dd/missing",
|
||||||
|
"nostr":{"names":{"_":"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"}}}
|
||||||
|
""".trimIndent(),
|
||||||
|
)
|
||||||
|
// dd/missing is intentionally NOT registered.
|
||||||
|
}
|
||||||
|
val resolver = NamecoinNameResolver(client, lookupTimeoutMs = 1_000L)
|
||||||
|
val result = resolver.resolve("testls.bit")
|
||||||
|
assertNotNull(result)
|
||||||
|
assertEquals(
|
||||||
|
"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c",
|
||||||
|
result?.pubkey,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private fun parse(s: String): JsonObject = json.parseToJsonElement(s).jsonObject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-memory ElectrumX double. Lets each test register the records its
|
||||||
|
* lookup will see and asserts on the names actually queried.
|
||||||
|
*/
|
||||||
|
private class FakeElectrumXClient : IElectrumXClient {
|
||||||
|
private val records = mutableMapOf<String, String>()
|
||||||
|
val queriedNames: MutableList<String> = mutableListOf()
|
||||||
|
|
||||||
|
fun register(
|
||||||
|
name: String,
|
||||||
|
value: String,
|
||||||
|
) {
|
||||||
|
records[name] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun nameShowWithFallback(
|
||||||
|
identifier: String,
|
||||||
|
servers: List<ElectrumxServer>,
|
||||||
|
): NameShowResult? {
|
||||||
|
queriedNames += identifier
|
||||||
|
val value = records[identifier] ?: return null
|
||||||
|
return NameShowResult(name = identifier, value = value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user