Merge pull request #2707 from mstrofnone/feat/namecoin-import-nip05-only

feat(namecoin): resolve `import` items per ifa-0001 (NIP-05 only)
This commit is contained in:
Vitor Pamplona
2026-05-03 12:20:41 -04:00
committed by GitHub
3 changed files with 840 additions and 4 deletions
@@ -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,
)
}
@@ -210,10 +210,11 @@ class NamecoinNameResolver(
private suspend fun performLookup(parsed: ParsedIdentifier): NamecoinNostrResult? {
val nameResult = electrumxClient.nameShowWithFallback(parsed.namecoinName, serverListProvider()) ?: return null
val valueJson = tryParseJson(nameResult.value) ?: return null
val merged = expandImportsIfPresent(valueJson)
return when (parsed.namespace) {
Namespace.DOMAIN -> extractFromDomainValue(valueJson, parsed)
Namespace.IDENTITY -> extractFromIdentityValue(valueJson, parsed)
Namespace.DOMAIN -> extractFromDomainValue(merged, parsed)
Namespace.IDENTITY -> extractFromIdentityValue(merged, parsed)
}
}
@@ -236,11 +237,12 @@ class NamecoinNameResolver(
val valueJson =
tryParseJson(nameResult.value)
?: return NamecoinResolveOutcome.NoNostrField(parsed.namecoinName)
val merged = expandImportsIfPresent(valueJson)
val nostrResult =
when (parsed.namespace) {
Namespace.DOMAIN -> extractFromDomainValue(valueJson, parsed)
Namespace.IDENTITY -> extractFromIdentityValue(valueJson, parsed)
Namespace.DOMAIN -> extractFromDomainValue(merged, parsed)
Namespace.IDENTITY -> extractFromIdentityValue(merged, parsed)
}
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.
*
@@ -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)
}
}
}