From 3835622fc596934529e9a16e0fbc77d771d93eb5 Mon Sep 17 00:00:00 2001 From: mstrofnone Date: Sun, 3 May 2026 09:53:10 +1000 Subject: [PATCH] feat(namecoin): resolve `import` items per ifa-0001 (NIP-05 only) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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/"` 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. --- .../namecoin/NamecoinImportResolver.kt | 276 +++++++++ .../namecoin/NamecoinNameResolver.kt | 35 +- .../nip05/namecoin/NamecoinImportTest.kt | 533 ++++++++++++++++++ 3 files changed, 840 insertions(+), 4 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinImportResolver.kt create mode 100644 quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinImportTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinImportResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinImportResolver.kt new file mode 100644 index 000000000..2a71fd38c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinImportResolver.kt @@ -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, + ): 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(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(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? { + // 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, + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt index 4d4a44aa8..135f65334 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/namecoin/NamecoinNameResolver.kt @@ -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. * diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinImportTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinImportTest.kt new file mode 100644 index 000000000..0edfb8723 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinImportTest.kt @@ -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() + val queriedNames: MutableList = mutableListOf() + + fun register( + name: String, + value: String, + ) { + records[name] = value + } + + override suspend fun nameShowWithFallback( + identifier: String, + servers: List, + ): NameShowResult? { + queriedNames += identifier + val value = records[identifier] ?: return null + return NameShowResult(name = identifier, value = value) + } + } +}