feat(namecoin): distinguish malformed-JSON values from missing-field

When a Namecoin record's value isn't valid JSON (a real failure mode
when an operator hand-builds the value and miscounts braces), the
NIP-05 path used to silently swallow the parser exception and surface
a misleading "no nostr field" message. That sends the publisher
chasing a phantom missing field when the actual problem is the value
itself.

Concrete case that triggered this: a `name_update` published a
474-byte d/testls value with one closing brace short of balanced. The
string parses up to the missing brace, after which kotlinx.serialization
throws "Unfinished JSON term at EOF at line 1, column 474". That error
was previously dropped, leaving the operator to debug "no nostr field"
without ever seeing the underlying JSON parse failure.

Changes:

- New NamecoinResolveOutcome.MalformedRecord(name, error). Distinct
  from NoNostrField. The `error` field is the parser's own diagnostic
  (e.g. "Unfinished JSON term at EOF at line 1, column 474") so the
  publisher can locate the broken byte without spelunking.
- NamecoinNameResolver.performLookupDetailed: parse via a new
  parseValueOrError helper and surface MalformedRecord instead of
  collapsing into NoNostrField. Also rejects non-object top-level
  values (arrays, primitives, null) with a useful diagnostic
  ("top-level value is JsonArray, expected JSON object").
- DesktopSearchScreen handles the new outcome by surfacing the parser
  error verbatim in the Namecoin status banner, so the column number
  reaches the publisher's screen.

Tests (commonTest / NamecoinImportTest):

- "NIP-05 lookup surfaces MalformedRecord with parser detail when
  value is broken JSON": a deliberately one-brace-short value yields
  MalformedRecord with a non-empty diagnostic.
- "NIP-05 lookup surfaces MalformedRecord when top-level value is a
  JSON array": ensures non-object top-level values are rejected with
  a useful "expected JSON object" message rather than silently
  parsing as something unusable.

Tests don't pin the exact parser wording (kotlinx.serialization can
change it across versions); they only pin that the message is
attributed to JSON parsing rather than to a missing field.
This commit is contained in:
m
2026-05-09 10:02:26 +10:00
parent 8b8c0a10e9
commit 3383b60315
3 changed files with 113 additions and 2 deletions
@@ -502,6 +502,62 @@ class NamecoinImportTest {
)
}
@Test
fun `NIP-05 lookup surfaces MalformedRecord with parser detail when value is broken JSON`() =
runTest {
// Real-world failure mode: a hand-built `name_update` with one
// closing brace short of balanced ends up published as broken
// JSON. The publisher then sees "name not found / no nostr
// field" and chases a phantom bug. Surface the parser's
// diagnostic so they know the value itself is the problem.
val truncated =
"""{"ip":"1.2.3.4","nostr":{"names":{"_":"460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"}}""".trimIndent()
val client =
FakeElectrumXClient().apply {
register("d/broken", truncated)
}
val resolver = NamecoinNameResolver(client, lookupTimeoutMs = 1_000L)
val outcome = resolver.resolveDetailed("_@broken.bit")
assertTrue(
"expected MalformedRecord, got $outcome",
outcome is NamecoinResolveOutcome.MalformedRecord,
)
outcome as NamecoinResolveOutcome.MalformedRecord
assertEquals("d/broken", outcome.name)
// Don't pin the exact parser text — different kotlinx.serialization
// versions phrase it differently. Just require it's a non-empty
// diagnostic.
assertTrue(
"error must be a non-empty diagnostic: ${outcome.error}",
outcome.error.isNotBlank(),
)
}
@Test
fun `NIP-05 lookup surfaces MalformedRecord when top-level value is a JSON array`() =
runTest {
// Top-level non-object values (arrays, primitives, null) are
// not valid Domain Name Objects per ifa-0001. They should be
// rejected with a useful diagnostic, not collapsed into
// NoNostrField (which implies the JSON parsed fine).
val client =
FakeElectrumXClient().apply {
register("d/arr", """["not","an","object"]""")
}
val resolver = NamecoinNameResolver(client, lookupTimeoutMs = 1_000L)
val outcome = resolver.resolveDetailed("_@arr.bit")
assertTrue(
"expected MalformedRecord, got $outcome",
outcome is NamecoinResolveOutcome.MalformedRecord,
)
outcome as NamecoinResolveOutcome.MalformedRecord
assertEquals("d/arr", outcome.name)
assertTrue(
"error must mention the unexpected top-level shape: ${outcome.error}",
outcome.error.contains("expected JSON object", ignoreCase = true),
)
}
// ── Helpers ──────────────────────────────────────────────────────────
private fun parse(s: String): JsonObject = json.parseToJsonElement(s).jsonObject