Merge pull request #2801 from mstrofnone/feat/namecoin-malformed-json-diagnostic

feat(namecoin): distinguish malformed-JSON values from missing-field
This commit is contained in:
Vitor Pamplona
2026-05-08 20:09:12 -04:00
committed by GitHub
3 changed files with 113 additions and 2 deletions
@@ -201,6 +201,13 @@ fun SearchScreen(
NamecoinResolveState.Error("Name exists but has no Nostr pubkey")
}
is NamecoinResolveOutcome.MalformedRecord -> {
// Surface the parser detail verbatim so the publisher
// of the broken record can locate the bad byte
// (kotlinx.serialization includes a column number).
NamecoinResolveState.Error("Namecoin record JSON is malformed: ${outcome.error}")
}
is NamecoinResolveOutcome.ServersUnreachable -> {
NamecoinResolveState.Error("ElectrumX servers unreachable — check your connection or try again")
}
@@ -43,6 +43,20 @@ sealed class NamecoinResolveOutcome {
val name: String,
) : NamecoinResolveOutcome()
/**
* The name exists but its value is not parseable as a Namecoin Domain
* Name Object (i.e. valid JSON of the right shape). Distinct from
* [NoNostrField], which means valid JSON but no `nostr` data.
*
* `error` is the underlying parser message (kotlinx.serialization);
* useful for surfacing back to the publisher of the broken record
* (e.g. "Unfinished JSON term at EOF at line 1, column 474").
*/
data class MalformedRecord(
val name: String,
val error: String,
) : NamecoinResolveOutcome()
/** All ElectrumX servers were unreachable. */
data class ServersUnreachable(
val message: String,
@@ -234,9 +248,23 @@ class NamecoinNameResolver(
)
}
// Distinguish "valid JSON, no `nostr` field" from "value is
// unparseable JSON". Both used to collapse into NoNostrField, which
// sent operators chasing a missing field that wasn't actually the
// problem (the Namecoin record itself was malformed). Surfacing
// the parser's column number lets a publisher see WHERE the value
// is bad without spelunking through the whole serialised form.
val valueJson =
tryParseJson(nameResult.value)
?: return NamecoinResolveOutcome.NoNostrField(parsed.namecoinName)
parseValueOrError(nameResult.value)
.fold(
onSuccess = { it },
onFailure = { err ->
return NamecoinResolveOutcome.MalformedRecord(
name = parsed.namecoinName,
error = err.message ?: "unparseable JSON value",
)
},
)
val merged = expandImportsIfPresent(valueJson)
val nostrResult =
@@ -473,5 +501,25 @@ class NamecoinNameResolver(
null
}
/**
* Parse [raw] into a [JsonObject], surfacing the underlying parser
* error message instead of swallowing it. The result is used by
* [performLookupDetailed] to distinguish [NamecoinResolveOutcome.MalformedRecord]
* (the value itself is broken) from [NamecoinResolveOutcome.NoNostrField]
* (valid JSON, just missing the `nostr` block).
*
* Returns [Result.failure] with the parser's diagnostic message; the
* caller is expected to forward that text into a UI / log surface so
* the publisher of the broken record can fix it.
*/
private fun parseValueOrError(raw: String): Result<JsonObject> =
runCatching {
val element = json.parseToJsonElement(raw)
element as? JsonObject
?: throw IllegalArgumentException(
"top-level value is ${element::class.simpleName}, expected JSON object",
)
}
private fun isValidPubkey(s: String): Boolean = HEX_PUBKEY_REGEX.matches(s)
}
@@ -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