refactor: move NIP-47 and CLINK to kotlinx only, and make their parsers forgiving

These were the last two reflective JSON bindings in the app. Jackson's hand-written
NWC deserializers dispatched to ~35 concrete classes with treeToValue(), and CLINK
went through readValue<T>() with no registered deserializer at all — so the field
names of 106 classes were load-bearing under R8 and cost two package keeps.

OptimizedJsonMapper.jvmAndroid now routes Request/Response/Notification and the
seven CLINK types at the hand-written kotlinx serializers that already existed in
commonMain and already were the production path on iOS. Nothing new had to be
written to replace Jackson; the Jackson (de)serializers for these types, and
OmitNullsMixin, are deleted. Everything else stays on Jackson, which is faster on
the event hot path and non-reflective there.

    seeds under nip47WalletConnect.rpc   851 -> 19
    seeds under experimental.clink       793 ->  9
    DEX                              31.11 MB -> 30.96 MB
    reflection contract              31 checks -> 24

PayInvoiceParams is now gone from the DEX entirely: R8 merged it away, which the
keep rule had been forbidding. It is not deleted (usage.txt lists only members),
the request serializer survives, and the wire-shape tests pass.

**Forgiving parsing.** The serializers read every field through new helpers in
nip01Core.kotlinSerialization.LenientJson, replacing 176 raw `.jsonPrimitive` /
`.jsonObject` accesses that threw on the wrong shape. The rule now: a field that is
missing, null, or the wrong type reads as null and the rest of the message still
parses. That is deliberately more forgiving than Jackson was — it ignored unknown
properties but still raised MismatchedInputException when a declared String arrived
as an object, losing a settled payment's preimage over one bad neighbouring field.

Covered by 22 new tests across NWC and CLINK: unknown result_types, unknown extra
fields, integers quoted as strings, booleans as 1/0, explicit nulls, a wrong-shaped
field costing only itself, a broken entry in a list leaving the good ones, and a
lone value where a list belongs (Jackson's ACCEPT_SINGLE_VALUE_AS_ARRAY, preserved).

An unrecognised `result_type` no longer throws. NIP-47 grows and the wallet is
somebody else's software, so those arrive as the new NwcUnknownResponse carrying
their result intact. It is deliberately not an IErrorResponseLike: an unknown
answer is not a refusal. Response is abstract, not sealed, so no consumer breaks.
An unknown *request* method still throws — Request is sealed, we are a client, and
that one is left for a separate decision.

Two things found on the way:

- toAnyValue() tried Double before Long, so a metadata integer round-tripped 42 as
  42.0 and changed the bytes of a field we only carry. Fixed the precedence.
- toAnyValue/toAnyMap lived under nip47 while CLINK imported them across packages;
  moved beside their inverse anyToJsonElement in nip01Core.

Verified: full ./gradlew test green, R8 release build green, 24/24 contract checks,
and the NWC/CLINK classes confirmed renamed in the shipped DEX. The NWC payment and
CLINK offer paths are the ones to exercise on a device — R8 can optimize these
classes for the first time, and no unit test runs against the minified APK.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DEoxktEZyTrAS33vVZBiwm
This commit is contained in:
Claude
2026-09-19 03:40:05 +00:00
parent 9f0e8049de
commit 475da3ff3c
25 changed files with 807 additions and 1020 deletions
+4 -9
View File
@@ -161,15 +161,10 @@
# names come from the Kotlin constructor parameter names, so a renamed field is
# a changed wire format.
# NIP-47 Wallet Connect RPC: every *Method / *Params / *SuccessResponse plus
# NwcError, NwcTransaction and the NwcMethod/NwcErrorCode enums are reached via
# treeToValue() from RequestDeserializer / ResponseDeserializer /
# NotificationDeserializer.
-keep class com.vitorpamplona.quartz.nip47WalletConnect.rpc.** { *; }
# CLINK (experimental NIP-XX offers/debits/manage): parsed with
# OptimizedJsonMapper.fromJsonTo<OfferRequest>() and friends — reflective.
-keep class com.vitorpamplona.quartz.experimental.clink.** { *; }
# NIP-47 and CLINK used to need a package keep each, because Jackson bound their
# ~106 concrete classes reflectively. Both are gone: OptimizedJsonMapper routes
# those types at the hand-written kotlinx serializers, which name every field as a
# string literal. Nothing to keep, nothing to verify.
# On-disk JSON written and re-read by the app itself. The field names are the
# file format, so renaming them makes every existing file unreadable.
+7 -14
View File
@@ -20,20 +20,13 @@
-keep class com.lambdaworks.crypto.** { *; }
-keep class com.lambdaworks.jni.** { *; }
# Jackson data binding, reflective paths only.
#
# Nearly all of Quartz's wire format is handled by the hand-written
# StdSerializer/StdDeserializer pairs registered on JacksonMapper (Event,
# Filter, Message, Command, Rumor, EventTemplate, TagArray, the NIP-46 Bunker
# messages) and JsonMapperNip55 (IntentResult, Permission). Those read and
# write property names as string literals, so their fields are free to be
# renamed.
#
# These two trees are not: they are data-bound reflectively, via
# `treeToValue(...)` and `OptimizedJsonMapper.fromJsonTo<T>()`, which derive the
# JSON property names from the Kotlin constructor parameter names.
-keep class com.vitorpamplona.quartz.nip47WalletConnect.rpc.** { *; }
-keep class com.vitorpamplona.quartz.experimental.clink.** { *; }
# No Jackson keeps. Every wire format Quartz speaks is now handled by a
# hand-written serializer that names its fields as string literals: the
# StdSerializer/StdDeserializer pairs registered on JacksonMapper (Event, Filter,
# Message, Command, Rumor, EventTemplate, TagArray, the NIP-46 Bunker messages),
# JsonMapperNip55 (IntentResult, Permission), and the kotlinx serializers that
# NIP-47 and CLINK route through on every target. None of it reads a Kotlin
# constructor parameter name at runtime, so none of it has to survive R8.
# Quartz serialises enums by name (Jackson writes/reads Enum.name, and
# Enum.valueOf resolves that string against the static field name), so the
@@ -34,14 +34,19 @@ import com.vitorpamplona.quartz.experimental.clink.offers.OfferReceipt
import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest
import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.anyToJsonElement
import com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.toAnyMap
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.decodeRootJsonObject
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.intOrNull
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.longOrNull
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.objOrNull
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.stringListOrNull
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.stringOrNull
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.toAnyMap
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonNull
@@ -51,7 +56,6 @@ import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
@@ -60,19 +64,13 @@ import kotlinx.serialization.json.put
* (`OfferRequest`/`OfferResponse`/`OfferReceipt`, `DebitRequest`/`DebitResponse`,
* `ManageRequest`/`ManageResponse`). They mirror what Jackson does reflectively on
* JVM/Android — including coercing a lone `details` object into a one-element list
* (Jackson's `ACCEPT_SINGLE_VALUE_AS_ARRAY`) — so native targets parse the same wire shapes.
* (Jackson's `ACCEPT_SINGLE_VALUE_AS_ARRAY`) — so every target parses the same wire shapes.
*
* Field readers come from `nip01Core.kotlinSerialization.LenientJson`, shared with the
* NIP-47 serializers. The private copies that used to sit here read
* `it.jsonPrimitive.content`, which throws the moment a peer sends an object or an array
* where a string belongs — one malformed field took down the whole offer.
*/
private fun JsonObject.stringOrNull(key: String): String? = get(key)?.let { if (it is JsonNull) null else it.jsonPrimitive.content }
private fun JsonObject.longOrNull(key: String): Long? = get(key)?.let { if (it is JsonNull) null else it.jsonPrimitive.longOrNull }
private fun JsonObject.intOrNull(key: String): Int? = get(key)?.let { if (it is JsonNull) null else it.jsonPrimitive.intOrNull }
private fun JsonObject.objectOrNull(key: String): JsonObject? = get(key) as? JsonObject
private fun JsonObject.stringListOrNull(key: String): List<String>? = (get(key) as? JsonArray)?.map { it.jsonPrimitive.content }
private fun serializeSatRange(range: SatRange): JsonObject =
buildJsonObject {
range.min?.let { put("min", it) }
@@ -117,11 +115,11 @@ object OfferRequestKSerializer : KSerializer<OfferRequest> {
}
override fun deserialize(decoder: Decoder): OfferRequest {
val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject
val obj = decoder.decodeRootJsonObject("A CLINK message")
return OfferRequest(
offer = obj.stringOrNull("offer"),
amount_sats = obj.longOrNull("amount_sats"),
payer_data = obj.objectOrNull("payer_data")?.toAnyMap(),
payer_data = obj.objOrNull("payer_data")?.toAnyMap(),
zap = obj.stringOrNull("zap"),
expires_in_seconds = obj.longOrNull("expires_in_seconds"),
description = obj.stringOrNull("description"),
@@ -148,12 +146,12 @@ object OfferResponseKSerializer : KSerializer<OfferResponse> {
}
override fun deserialize(decoder: Decoder): OfferResponse {
val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject
val obj = decoder.decodeRootJsonObject("A CLINK message")
return OfferResponse(
bolt11 = obj.stringOrNull("bolt11"),
error = obj.stringOrNull("error"),
code = obj.intOrNull("code"),
range = obj.objectOrNull("range")?.let { parseSatRange(it) },
range = obj.objOrNull("range")?.let { parseSatRange(it) },
latest = obj.stringOrNull("latest"),
)
}
@@ -175,7 +173,7 @@ object OfferReceiptKSerializer : KSerializer<OfferReceipt> {
}
override fun deserialize(decoder: Decoder): OfferReceipt {
val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject
val obj = decoder.decodeRootJsonObject("A CLINK message")
return OfferReceipt(
res = obj.stringOrNull("res"),
preimage = obj.stringOrNull("preimage"),
@@ -203,14 +201,14 @@ object DebitRequestKSerializer : KSerializer<DebitRequest> {
}
override fun deserialize(decoder: Decoder): DebitRequest {
val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject
val obj = decoder.decodeRootJsonObject("A CLINK message")
return DebitRequest(
pointer = obj.stringOrNull("pointer"),
amount_sats = obj.longOrNull("amount_sats"),
bolt11 = obj.stringOrNull("bolt11"),
description = obj.stringOrNull("description"),
k1 = obj.stringOrNull("k1"),
frequency = obj.objectOrNull("frequency")?.let { parseDebitFrequency(it) },
frequency = obj.objOrNull("frequency")?.let { parseDebitFrequency(it) },
)
}
@@ -248,15 +246,15 @@ object DebitResponseKSerializer : KSerializer<DebitResponse> {
}
override fun deserialize(decoder: Decoder): DebitResponse {
val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject
val obj = decoder.decodeRootJsonObject("A CLINK message")
return DebitResponse(
res = obj.stringOrNull("res"),
preimage = obj.stringOrNull("preimage"),
code = obj.intOrNull("code"),
error = obj.stringOrNull("error"),
range = obj.objectOrNull("range")?.let { parseSatRange(it) },
range = obj.objOrNull("range")?.let { parseSatRange(it) },
retry_after = obj.longOrNull("retry_after"),
delta = obj.objectOrNull("delta")?.let { parseGfyDelta(it) },
delta = obj.objOrNull("delta")?.let { parseGfyDelta(it) },
)
}
}
@@ -279,12 +277,12 @@ object ManageRequestKSerializer : KSerializer<ManageRequest> {
}
override fun deserialize(decoder: Decoder): ManageRequest {
val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject
val obj = decoder.decodeRootJsonObject("A CLINK message")
return ManageRequest(
resource = obj.stringOrNull("resource"),
action = obj.stringOrNull("action"),
pointer = obj.stringOrNull("pointer"),
offer = obj.objectOrNull("offer")?.let { parseManageOffer(it) },
offer = obj.objOrNull("offer")?.let { parseManageOffer(it) },
)
}
@@ -297,7 +295,7 @@ object ManageRequestKSerializer : KSerializer<ManageRequest> {
private fun parseManageOffer(obj: JsonObject): ManageOffer =
ManageOffer(
id = obj.stringOrNull("id"),
fields = obj.objectOrNull("fields")?.let { parseOfferFields(it) },
fields = obj.objOrNull("fields")?.let { parseOfferFields(it) },
)
private fun serializeOfferFields(fields: OfferFields): JsonObject =
@@ -342,7 +340,7 @@ object ManageResponseKSerializer : KSerializer<ManageResponse> {
}
override fun deserialize(decoder: Decoder): ManageResponse {
val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject
val obj = decoder.decodeRootJsonObject("A CLINK message")
return ManageResponse(
res = obj.stringOrNull("res"),
resource = obj.stringOrNull("resource"),
@@ -350,9 +348,9 @@ object ManageResponseKSerializer : KSerializer<ManageResponse> {
code = obj.intOrNull("code"),
error = obj.stringOrNull("error"),
field = obj.stringOrNull("field"),
range = obj.objectOrNull("range")?.let { parseSatRange(it) },
range = obj.objOrNull("range")?.let { parseSatRange(it) },
retry_after = obj.longOrNull("retry_after"),
delta = obj.objectOrNull("delta")?.let { parseGfyDelta(it) },
delta = obj.objOrNull("delta")?.let { parseGfyDelta(it) },
)
}
@@ -21,8 +21,10 @@
package com.vitorpamplona.quartz.nip01Core.kotlinSerialization
import com.vitorpamplona.quartz.nip01Core.core.RawJson
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonUnquotedLiteral
import kotlinx.serialization.json.add
@@ -56,3 +58,32 @@ fun anyToJsonElement(value: Any?): JsonElement =
is Array<*> -> buildJsonArray { value.forEach { add(anyToJsonElement(it)) } }
else -> JsonPrimitive(value.toString())
}
/**
* The inverse of [anyToJsonElement]: decodes a [JsonElement] back into the untyped
* `Any?` tree those free-form fields are modelled as.
*
* An unquoted primitive is narrowed to the most specific type it parses as, so a
* round trip through JSON does not turn `true` into `"true"`. Integers are tried
* BEFORE doubles: `toDoubleOrNull` happily accepts "42" and answers 42.0, which
* then re-encodes as `42.0` and changes the bytes of a metadata field we were only
* meant to carry. A quoted primitive stays a String, because the quotes are the
* peer telling us it is one.
*
* Lives here, not under a NIP, for the same reason [anyToJsonElement] does — CLINK
* and NIP-47 both need it, and a per-NIP copy is how the two backends drift.
*/
fun JsonElement.toAnyValue(): Any =
when (this) {
is JsonPrimitive ->
if (isString) {
content
} else {
content.toBooleanStrictOrNull() ?: content.toLongOrNull() ?: content.toDoubleOrNull() ?: content
}
is JsonObject -> toAnyMap()
is JsonArray -> map { it.toAnyValue() }
}
fun JsonObject.toAnyMap(): Map<String, Any?> = entries.associate { it.key to it.value.toAnyValue() }
@@ -0,0 +1,143 @@
/*
* 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.nip01Core.kotlinSerialization
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* Field readers for JSON written by somebody else.
*
* These back the hand-written NIP-47 and CLINK serializers, where every byte
* arrives from a third-party wallet or client. The rule they all follow: a field
* that is missing, null, or of the wrong shape reads as `null` and the rest of
* the message still parses. Nothing here throws.
*
* That is deliberately MORE forgiving than what Jackson did on this path. Jackson
* ignored unknown properties but still raised MismatchedInputException when a
* declared `String` arrived as an object, taking the whole message down over one
* bad field. A payment response whose `fees_paid` is `"12"` instead of `12`, or
* whose `metadata` is a string instead of an object, is worth reading for the
* preimage it does carry.
*
* Use `jsonObject` / `jsonPrimitive` / `.content` directly only where the value
* is ours and its shape is guaranteed — they throw, which is what these avoid.
*
* The base case: JSON `null` carries no more information than an absent key, so
* every reader here treats the two alike.
*/
private fun JsonElement?.presentOrNull(): JsonElement? = if (this == null || this is JsonNull) null else this
fun JsonElement?.asObjectOrNull(): JsonObject? = presentOrNull() as? JsonObject
/**
* The element as an array — wrapping a lone value in a one-element array, which is
* what Jackson's ACCEPT_SINGLE_VALUE_AS_ARRAY did here. Several Nostr-native RPCs
* (CLINK Manage `details`, typed `OfferData | OfferData[]`) answer with a bare
* object for a single result and an array for a list.
*/
fun JsonElement?.asArrayOrNull(): JsonArray? =
when (val e = presentOrNull()) {
null -> null
is JsonArray -> e
else -> JsonArray(listOf(e))
}
private fun JsonElement?.asPrimitiveOrNull(): JsonPrimitive? = presentOrNull() as? JsonPrimitive
// ---- object field readers ---------------------------------------------------
fun JsonObject.objOrNull(key: String): JsonObject? = this[key].asObjectOrNull()
fun JsonObject.arrayOrNull(key: String): JsonArray? = this[key].asArrayOrNull()
/** Text content, or null when the key is absent, null, an object or an array. */
fun JsonObject.stringOrNull(key: String): String? = this[key].asPrimitiveOrNull()?.content
/**
* Like [stringOrNull] but drops the empty string too, for the many fields where a
* peer writes `""` to mean "I have nothing for this".
*/
fun JsonObject.nonEmptyStringOrNull(key: String): String? = stringOrNull(key)?.ifBlank { null }
/** Accepts `12`, `"12"` and `12.0` — wallets send all three for the same field. */
fun JsonObject.longOrNull(key: String): Long? {
val raw = this[key].asPrimitiveOrNull()?.content ?: return null
return raw.toLongOrNull() ?: raw.toDoubleOrNull()?.takeIf { it.isFinite() }?.toLong()
}
fun JsonObject.intOrNull(key: String): Int? = longOrNull(key)?.toInt()
fun JsonObject.doubleOrNull(key: String): Double? = this[key].asPrimitiveOrNull()?.content?.toDoubleOrNull()
/** Accepts `true`/`false`, `"true"`/`"false"`, and the `1`/`0` some wallets send. */
fun JsonObject.booleanOrNull(key: String): Boolean? {
val raw = this[key].asPrimitiveOrNull()?.content ?: return null
return raw.toBooleanStrictOrNull()
?: when (raw) {
"1" -> true
"0" -> false
else -> null
}
}
/**
* Every string in the array, skipping entries that are not primitives rather than
* failing the array. A lone string is read as a single-element list.
*/
fun JsonObject.stringListOrNull(key: String): List<String>? = arrayOrNull(key)?.mapNotNull { it.asPrimitiveOrNull()?.content }
/** The object as a plain map, or null when the key holds anything else. */
fun JsonObject.anyMapOrNull(key: String): Map<String, Any?>? = objOrNull(key)?.toAnyMap()
/** Every object in the array, skipping entries that are not objects. */
fun JsonObject.objectListOrNull(key: String): List<JsonObject>? = arrayOrNull(key)?.mapNotNull { it.asObjectOrNull() }
/** A shape name for error messages that survives R8 — `::class.simpleName` does not. */
private fun JsonElement.shapeName(): String =
when (this) {
is JsonNull -> "null"
is JsonPrimitive -> if (isString) "a string" else "a number or boolean"
is JsonArray -> "an array"
is JsonObject -> "an object"
}
/**
* The root of the message as an object.
*
* The one place these serializers still refuse input: a payload whose root is an
* array, a bare string or null is not a partially-readable message, it is not a
* message. Fails with an [IllegalArgumentException] naming what arrived, rather
* than the ClassCastException `decodeJsonElement().jsonObject` raises.
*/
fun Decoder.decodeRootJsonObject(what: String): JsonObject {
val decoder =
this as? JsonDecoder
?: throw IllegalArgumentException("$what can only be read from JSON")
val element = decoder.decodeJsonElement()
return element as? JsonObject
?: throw IllegalArgumentException("$what must be a JSON object, but the payload is ${element.shapeName()}")
}
@@ -1,48 +0,0 @@
/*
* 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.nip47WalletConnect.kotlinSerialization
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
// Helper function to convert JsonElement to standard Kotlin types recursively
fun JsonElement.toAnyValue(): Any =
when (this) {
is JsonPrimitive -> {
if (isString) {
content
} else {
content.toBooleanStrictOrNull() ?: content.toDoubleOrNull() ?: content.toLongOrNull() ?: content
}
}
is JsonObject -> {
toAnyMap()
}
is JsonArray -> {
map { it.toAnyValue() }
}
}
fun JsonObject.toAnyMap(): Map<String, Any?> = entries.associate { it.key to it.value.toAnyValue() }
@@ -20,6 +20,10 @@
*/
package com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.decodeRootJsonObject
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.longOrNull
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.objOrNull
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.stringOrNull
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedData
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification
@@ -33,10 +37,8 @@ import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
@@ -88,16 +90,15 @@ object Nip47NotificationKSerializer : KSerializer<Notification> {
override fun deserialize(decoder: Decoder): Notification {
val jsonDecoder = decoder as JsonDecoder
val jsonObject = jsonDecoder.decodeJsonElement().jsonObject
val notificationType =
jsonObject["notification_type"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content }
val jsonObject = decoder.decodeRootJsonObject("An NWC notification")
val notificationType = jsonObject.stringOrNull("notification_type")
return when (notificationType) {
NwcNotificationType.PAYMENT_RECEIVED -> {
PaymentReceivedNotification(
notification =
Nip47ResponseKSerializer.parseTransaction(
jsonObject["notification"]?.jsonObject,
jsonObject.objOrNull("notification"),
),
)
}
@@ -106,24 +107,24 @@ object Nip47NotificationKSerializer : KSerializer<Notification> {
PaymentSentNotification(
notification =
Nip47ResponseKSerializer.parseTransaction(
jsonObject["notification"]?.jsonObject,
jsonObject.objOrNull("notification"),
),
)
}
NwcNotificationType.HOLD_INVOICE_ACCEPTED -> {
val notifObj = jsonObject["notification"]?.jsonObject
val notifObj = jsonObject.objOrNull("notification")
HoldInvoiceAcceptedNotification(
notification =
notifObj?.let {
HoldInvoiceAcceptedData(
type = it["type"]?.jsonPrimitive?.content,
invoice = it["invoice"]?.jsonPrimitive?.content,
payment_hash = it["payment_hash"]?.jsonPrimitive?.content,
amount = it["amount"]?.jsonPrimitive?.longOrNull,
created_at = it["created_at"]?.jsonPrimitive?.longOrNull,
expires_at = it["expires_at"]?.jsonPrimitive?.longOrNull,
settle_deadline = it["settle_deadline"]?.jsonPrimitive?.longOrNull,
type = it.stringOrNull("type"),
invoice = it.stringOrNull("invoice"),
payment_hash = it.stringOrNull("payment_hash"),
amount = it.longOrNull("amount"),
created_at = it.longOrNull("created_at"),
expires_at = it.longOrNull("expires_at"),
settle_deadline = it.longOrNull("settle_deadline"),
)
},
)
@@ -20,7 +20,16 @@
*/
package com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.anyMapOrNull
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.anyToJsonElement
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.booleanOrNull
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.decodeRootJsonObject
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.intOrNull
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.longOrNull
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.objOrNull
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.objectListOrNull
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.stringListOrNull
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.stringOrNull
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod
@@ -58,17 +67,13 @@ import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.add
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
@@ -258,8 +263,8 @@ object Nip47RequestKSerializer : KSerializer<Request> {
override fun deserialize(decoder: Decoder): Request {
val jsonDecoder = decoder as JsonDecoder
val jsonObject = jsonDecoder.decodeJsonElement().jsonObject
val method = jsonObject["method"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content }
val jsonObject = decoder.decodeRootJsonObject("An NWC request")
val method = jsonObject.stringOrNull("method")
return when (method) {
NwcMethod.PAY_INVOICE -> parsePayInvoice(jsonObject)
@@ -282,62 +287,61 @@ object Nip47RequestKSerializer : KSerializer<Request> {
}
private fun parsePayInvoice(json: JsonObject): PayInvoiceMethod {
val params = json["params"]?.jsonObject
val params = json.objOrNull("params")
return PayInvoiceMethod(
params?.let {
PayInvoiceParams(
invoice = it["invoice"]?.jsonPrimitive?.content,
amount = it["amount"]?.jsonPrimitive?.longOrNull,
metadata = it["metadata"]?.jsonObject?.toAnyMap(),
invoice = it.stringOrNull("invoice"),
amount = it.longOrNull("amount"),
metadata = it.anyMapOrNull("metadata"),
)
},
)
}
private fun parsePay(json: JsonObject): PayMethod {
val params = json["params"]?.jsonObject
val params = json.objOrNull("params")
return PayMethod(
params?.let {
// contentOrNull / `as? JsonObject` treat an explicit JSON `null` as absent —
// Jackson (JVM/Android) writes null-valued keys, so a native/iOS peer parsing
// that output must not read `JsonNull` as the string "null" or crash on it.
PayParams(
payment = it["payment"]?.jsonPrimitive?.contentOrNull,
amount = it["amount"]?.jsonPrimitive?.longOrNull,
payer_note = it["payer_note"]?.jsonPrimitive?.contentOrNull,
metadata = (it["metadata"] as? JsonObject)?.toAnyMap(),
payment = it.stringOrNull("payment"),
amount = it.longOrNull("amount"),
payer_note = it.stringOrNull("payer_note"),
metadata = it.anyMapOrNull("metadata"),
)
},
)
}
private fun parseReceive(json: JsonObject): ReceiveMethod {
val params = json["params"]?.jsonObject
val params = json.objOrNull("params")
return ReceiveMethod(
params?.let {
ReceiveParams(
amount = it["amount"]?.jsonPrimitive?.longOrNull,
description = it["description"]?.jsonPrimitive?.contentOrNull,
metadata = (it["metadata"] as? JsonObject)?.toAnyMap(),
amount = it.longOrNull("amount"),
description = it.stringOrNull("description"),
metadata = it.anyMapOrNull("metadata"),
)
},
)
}
private fun parsePayKeysend(json: JsonObject): PayKeysendMethod {
val params = json["params"]?.jsonObject
val params = json.objOrNull("params")
return PayKeysendMethod(
params?.let {
PayKeysendParams(
amount = it["amount"]?.jsonPrimitive?.longOrNull,
pubkey = it["pubkey"]?.jsonPrimitive?.content,
preimage = it["preimage"]?.jsonPrimitive?.content,
amount = it.longOrNull("amount"),
pubkey = it.stringOrNull("pubkey"),
preimage = it.stringOrNull("preimage"),
tlv_records =
it["tlv_records"]?.jsonArray?.map { record ->
val obj = record.jsonObject
it.objectListOrNull("tlv_records")?.map { record ->
TlvRecord(
type = obj["type"]?.jsonPrimitive?.longOrNull,
value = obj["value"]?.jsonPrimitive?.content,
type = record.longOrNull("type"),
value = record.stringOrNull("value"),
)
},
)
@@ -346,113 +350,113 @@ object Nip47RequestKSerializer : KSerializer<Request> {
}
private fun parseMakeInvoice(json: JsonObject): MakeInvoiceMethod {
val params = json["params"]?.jsonObject
val params = json.objOrNull("params")
return MakeInvoiceMethod(
params?.let {
MakeInvoiceParams(
amount = it["amount"]?.jsonPrimitive?.longOrNull,
description = it["description"]?.jsonPrimitive?.content,
description_hash = it["description_hash"]?.jsonPrimitive?.content,
expiry = it["expiry"]?.jsonPrimitive?.longOrNull,
metadata = it["metadata"]?.jsonObject?.toAnyMap(),
amount = it.longOrNull("amount"),
description = it.stringOrNull("description"),
description_hash = it.stringOrNull("description_hash"),
expiry = it.longOrNull("expiry"),
metadata = it.anyMapOrNull("metadata"),
)
},
)
}
private fun parseLookupInvoice(json: JsonObject): LookupInvoiceMethod {
val params = json["params"]?.jsonObject
val params = json.objOrNull("params")
return LookupInvoiceMethod(
params?.let {
LookupInvoiceParams(
payment_hash = it["payment_hash"]?.jsonPrimitive?.content,
invoice = it["invoice"]?.jsonPrimitive?.content,
payment_hash = it.stringOrNull("payment_hash"),
invoice = it.stringOrNull("invoice"),
)
},
)
}
private fun parseListTransactions(json: JsonObject): ListTransactionsMethod {
val params = json["params"]?.jsonObject
val params = json.objOrNull("params")
return ListTransactionsMethod(
params?.let {
ListTransactionsParams(
from = it["from"]?.jsonPrimitive?.longOrNull,
until = it["until"]?.jsonPrimitive?.longOrNull,
limit = it["limit"]?.jsonPrimitive?.intOrNull,
offset = it["offset"]?.jsonPrimitive?.intOrNull,
unpaid = it["unpaid"]?.jsonPrimitive?.booleanOrNull,
unpaid_outgoing = it["unpaid_outgoing"]?.jsonPrimitive?.booleanOrNull,
unpaid_incoming = it["unpaid_incoming"]?.jsonPrimitive?.booleanOrNull,
type = it["type"]?.jsonPrimitive?.content,
from = it.longOrNull("from"),
until = it.longOrNull("until"),
limit = it.intOrNull("limit"),
offset = it.intOrNull("offset"),
unpaid = it.booleanOrNull("unpaid"),
unpaid_outgoing = it.booleanOrNull("unpaid_outgoing"),
unpaid_incoming = it.booleanOrNull("unpaid_incoming"),
type = it.stringOrNull("type"),
)
},
)
}
private fun parseSignMessage(json: JsonObject): SignMessageMethod {
val params = json["params"]?.jsonObject
val params = json.objOrNull("params")
return SignMessageMethod(
params?.let {
SignMessageParams(
message = it["message"]?.jsonPrimitive?.content,
message = it.stringOrNull("message"),
)
},
)
}
private fun parseCreateConnection(json: JsonObject): CreateConnectionMethod {
val params = json["params"]?.jsonObject
val params = json.objOrNull("params")
return CreateConnectionMethod(
params?.let {
CreateConnectionParams(
pubkey = it["pubkey"]?.jsonPrimitive?.content,
name = it["name"]?.jsonPrimitive?.content,
request_methods = it["request_methods"]?.jsonArray?.map { m -> m.jsonPrimitive.content },
notification_types = it["notification_types"]?.jsonArray?.map { n -> n.jsonPrimitive.content },
max_amount = it["max_amount"]?.jsonPrimitive?.longOrNull,
budget_renewal = it["budget_renewal"]?.jsonPrimitive?.content,
expires_at = it["expires_at"]?.jsonPrimitive?.longOrNull,
isolated = it["isolated"]?.jsonPrimitive?.booleanOrNull,
metadata = it["metadata"]?.jsonObject?.toAnyMap(),
pubkey = it.stringOrNull("pubkey"),
name = it.stringOrNull("name"),
request_methods = it.stringListOrNull("request_methods"),
notification_types = it.stringListOrNull("notification_types"),
max_amount = it.longOrNull("max_amount"),
budget_renewal = it.stringOrNull("budget_renewal"),
expires_at = it.longOrNull("expires_at"),
isolated = it.booleanOrNull("isolated"),
metadata = it.anyMapOrNull("metadata"),
)
},
)
}
private fun parseMakeHoldInvoice(json: JsonObject): MakeHoldInvoiceMethod {
val params = json["params"]?.jsonObject
val params = json.objOrNull("params")
return MakeHoldInvoiceMethod(
params?.let {
MakeHoldInvoiceParams(
amount = it["amount"]?.jsonPrimitive?.longOrNull,
description = it["description"]?.jsonPrimitive?.content,
description_hash = it["description_hash"]?.jsonPrimitive?.content,
expiry = it["expiry"]?.jsonPrimitive?.longOrNull,
payment_hash = it["payment_hash"]?.jsonPrimitive?.content,
min_cltv_expiry_delta = it["min_cltv_expiry_delta"]?.jsonPrimitive?.intOrNull,
amount = it.longOrNull("amount"),
description = it.stringOrNull("description"),
description_hash = it.stringOrNull("description_hash"),
expiry = it.longOrNull("expiry"),
payment_hash = it.stringOrNull("payment_hash"),
min_cltv_expiry_delta = it.intOrNull("min_cltv_expiry_delta"),
)
},
)
}
private fun parseCancelHoldInvoice(json: JsonObject): CancelHoldInvoiceMethod {
val params = json["params"]?.jsonObject
val params = json.objOrNull("params")
return CancelHoldInvoiceMethod(
params?.let {
CancelHoldInvoiceParams(
payment_hash = it["payment_hash"]?.jsonPrimitive?.content,
payment_hash = it.stringOrNull("payment_hash"),
)
},
)
}
private fun parseSettleHoldInvoice(json: JsonObject): SettleHoldInvoiceMethod {
val params = json["params"]?.jsonObject
val params = json.objOrNull("params")
return SettleHoldInvoiceMethod(
params?.let {
SettleHoldInvoiceParams(
preimage = it["preimage"]?.jsonPrimitive?.content,
preimage = it.stringOrNull("preimage"),
)
},
)
@@ -20,7 +20,15 @@
*/
package com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.anyMapOrNull
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.anyToJsonElement
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.decodeRootJsonObject
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.longOrNull
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.objOrNull
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.objectListOrNull
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.stringListOrNull
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.stringOrNull
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.toAnyMap
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse
@@ -35,6 +43,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcUnknownResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse
@@ -55,10 +64,7 @@ import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
@@ -75,6 +81,10 @@ object Nip47ResponseKSerializer : KSerializer<Response> {
buildJsonObject {
put("result_type", value.resultType)
when (value) {
is NwcUnknownResponse -> {
value.result?.let { put("result", anyToJsonElement(it)) }
}
is NwcErrorResponse -> {
value.error?.let { put("error", serializeNwcError(it)) }
}
@@ -250,8 +260,8 @@ object Nip47ResponseKSerializer : KSerializer<Response> {
override fun deserialize(decoder: Decoder): Response {
val jsonDecoder = decoder as JsonDecoder
val jsonObject = jsonDecoder.decodeJsonElement().jsonObject
val resultType = jsonObject["result_type"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content }
val jsonObject = decoder.decodeRootJsonObject("An NWC response")
val resultType = jsonObject.stringOrNull("result_type")
val hasError = jsonObject["error"]?.let { it !is JsonNull } ?: false
val hasResult = jsonObject["result"]?.let { it !is JsonNull } ?: false
@@ -262,7 +272,7 @@ object Nip47ResponseKSerializer : KSerializer<Response> {
}
else -> {
val error = jsonObject["error"]?.jsonObject?.let { parseNwcError(it) }
val error = jsonObject.objOrNull("error")?.let { parseNwcError(it) }
NwcErrorResponse(resultType ?: "", error)
}
}
@@ -287,11 +297,11 @@ object Nip47ResponseKSerializer : KSerializer<Response> {
}
NwcMethod.MAKE_INVOICE -> {
MakeInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject))
MakeInvoiceSuccessResponse(parseTransaction(jsonObject.objOrNull("result")))
}
NwcMethod.LOOKUP_INVOICE -> {
LookupInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject))
LookupInvoiceSuccessResponse(parseTransaction(jsonObject.objOrNull("result")))
}
NwcMethod.LIST_TRANSACTIONS -> {
@@ -319,7 +329,7 @@ object Nip47ResponseKSerializer : KSerializer<Response> {
}
NwcMethod.MAKE_HOLD_INVOICE -> {
MakeHoldInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject))
MakeHoldInvoiceSuccessResponse(parseTransaction(jsonObject.objOrNull("result")))
}
NwcMethod.CANCEL_HOLD_INVOICE -> {
@@ -332,28 +342,33 @@ object Nip47ResponseKSerializer : KSerializer<Response> {
else -> {
// backward compatibility: guess by result content
val resultObj = jsonObject["result"]?.jsonObject
val resultObj = jsonObject.objOrNull("result")
if (resultObj?.containsKey("preimage") == true) {
return parsePayInvoiceSuccess(jsonObject)
}
throw IllegalArgumentException("Unknown NWC response type: $resultType")
// A result_type from a newer NIP-47, or an extension we do not
// implement. The response is well-formed; hand it back with the
// result intact rather than failing the parse.
NwcUnknownResponse(resultType ?: "", resultObj?.toAnyMap())
}
}
}
throw IllegalArgumentException("NWC response has neither result nor error")
// Neither result nor error nor result_type: nothing to dispatch on, but the
// payload was still a valid JSON object, so surface it rather than throw.
return NwcUnknownResponse(resultType ?: "", jsonObject.objOrNull("result")?.toAnyMap())
}
private fun parseNwcError(obj: JsonObject): NwcError {
val code =
obj["code"]?.jsonPrimitive?.content?.let { codeName ->
obj.stringOrNull("code")?.let { codeName ->
try {
NwcErrorCode.valueOf(codeName)
} catch (_: Exception) {
null
}
}
return NwcError(code, obj["message"]?.jsonPrimitive?.content)
return NwcError(code, obj.stringOrNull("message"))
}
fun serializeTransaction(transaction: NwcTransaction?): JsonObject? {
@@ -379,177 +394,177 @@ object Nip47ResponseKSerializer : KSerializer<Response> {
fun parseTransaction(obj: JsonObject?): NwcTransaction? {
if (obj == null) return null
return NwcTransaction(
type = obj["type"]?.jsonPrimitive?.content,
state = obj["state"]?.jsonPrimitive?.content,
invoice = obj["invoice"]?.jsonPrimitive?.content,
description = obj["description"]?.jsonPrimitive?.content,
description_hash = obj["description_hash"]?.jsonPrimitive?.content,
preimage = obj["preimage"]?.jsonPrimitive?.content,
payment_hash = obj["payment_hash"]?.jsonPrimitive?.content,
amount = obj["amount"]?.jsonPrimitive?.longOrNull,
fees_paid = obj["fees_paid"]?.jsonPrimitive?.longOrNull,
created_at = obj["created_at"]?.jsonPrimitive?.longOrNull,
expires_at = obj["expires_at"]?.jsonPrimitive?.longOrNull,
settled_at = obj["settled_at"]?.jsonPrimitive?.longOrNull,
settle_deadline = obj["settle_deadline"]?.jsonPrimitive?.longOrNull,
metadata = obj["metadata"]?.jsonObject?.toAnyMap(),
type = obj.stringOrNull("type"),
state = obj.stringOrNull("state"),
invoice = obj.stringOrNull("invoice"),
description = obj.stringOrNull("description"),
description_hash = obj.stringOrNull("description_hash"),
preimage = obj.stringOrNull("preimage"),
payment_hash = obj.stringOrNull("payment_hash"),
amount = obj.longOrNull("amount"),
fees_paid = obj.longOrNull("fees_paid"),
created_at = obj.longOrNull("created_at"),
expires_at = obj.longOrNull("expires_at"),
settled_at = obj.longOrNull("settled_at"),
settle_deadline = obj.longOrNull("settle_deadline"),
metadata = obj.anyMapOrNull("metadata"),
)
}
private fun parsePayInvoiceSuccess(json: JsonObject): PayInvoiceSuccessResponse {
val result = json["result"]?.jsonObject
val result = json.objOrNull("result")
return PayInvoiceSuccessResponse(
result?.let {
PayInvoiceSuccessResponse.PayInvoiceResultParams(
preimage = it["preimage"]?.jsonPrimitive?.content,
fees_paid = it["fees_paid"]?.jsonPrimitive?.longOrNull,
preimage = it.stringOrNull("preimage"),
fees_paid = it.longOrNull("fees_paid"),
)
},
)
}
private fun parsePayInvoiceError(json: JsonObject): PayInvoiceErrorResponse {
val error = json["error"]?.jsonObject
val error = json.objOrNull("error")
return PayInvoiceErrorResponse(
error?.let {
PayInvoiceErrorResponse.PayInvoiceErrorParams(
code =
it["code"]?.jsonPrimitive?.content?.let { codeName ->
it.stringOrNull("code")?.let { codeName ->
try {
NwcErrorCode.valueOf(codeName)
} catch (_: Exception) {
null
}
},
message = it["message"]?.jsonPrimitive?.content,
message = it.stringOrNull("message"),
)
},
)
}
private fun parsePaySuccess(json: JsonObject): PaySuccessResponse {
val result = json["result"]?.jsonObject
val result = json.objOrNull("result")
return PaySuccessResponse(
result?.let {
// contentOrNull, not content: an explicit JSON `null` (which Jackson writes
// for every null field) must read back as a real null, not the string "null".
PaySuccessResponse.PayResult(
transaction_id = it["transaction_id"]?.jsonPrimitive?.contentOrNull,
state = it["state"]?.jsonPrimitive?.contentOrNull,
instruction_type = it["instruction_type"]?.jsonPrimitive?.contentOrNull,
amount = it["amount"]?.jsonPrimitive?.longOrNull,
fees_paid = it["fees_paid"]?.jsonPrimitive?.longOrNull,
payment_hash = it["payment_hash"]?.jsonPrimitive?.contentOrNull,
preimage = it["preimage"]?.jsonPrimitive?.contentOrNull,
payer_proof = it["payer_proof"]?.jsonPrimitive?.contentOrNull,
txid = it["txid"]?.jsonPrimitive?.contentOrNull,
failure_reason = it["failure_reason"]?.jsonPrimitive?.contentOrNull,
created_at = it["created_at"]?.jsonPrimitive?.longOrNull,
settled_at = it["settled_at"]?.jsonPrimitive?.longOrNull,
transaction_id = it.stringOrNull("transaction_id"),
state = it.stringOrNull("state"),
instruction_type = it.stringOrNull("instruction_type"),
amount = it.longOrNull("amount"),
fees_paid = it.longOrNull("fees_paid"),
payment_hash = it.stringOrNull("payment_hash"),
preimage = it.stringOrNull("preimage"),
payer_proof = it.stringOrNull("payer_proof"),
txid = it.stringOrNull("txid"),
failure_reason = it.stringOrNull("failure_reason"),
created_at = it.longOrNull("created_at"),
settled_at = it.longOrNull("settled_at"),
)
},
)
}
private fun parseReceiveSuccess(json: JsonObject): ReceiveSuccessResponse {
val result = json["result"]?.jsonObject
val result = json.objOrNull("result")
return ReceiveSuccessResponse(
result?.let {
ReceiveSuccessResponse.ReceiveResult(
bip321 = it["bip321"]?.jsonPrimitive?.contentOrNull,
transaction_id = it["transaction_id"]?.jsonPrimitive?.contentOrNull,
bip321 = it.stringOrNull("bip321"),
transaction_id = it.stringOrNull("transaction_id"),
)
},
)
}
private fun parsePayKeysendSuccess(json: JsonObject): PayKeysendSuccessResponse {
val result = json["result"]?.jsonObject
val result = json.objOrNull("result")
return PayKeysendSuccessResponse(
result?.let {
PayKeysendSuccessResponse.PayKeysendResult(
preimage = it["preimage"]?.jsonPrimitive?.content,
fees_paid = it["fees_paid"]?.jsonPrimitive?.longOrNull,
preimage = it.stringOrNull("preimage"),
fees_paid = it.longOrNull("fees_paid"),
)
},
)
}
private fun parseListTransactionsSuccess(json: JsonObject): ListTransactionsSuccessResponse {
val result = json["result"]?.jsonObject
val result = json.objOrNull("result")
return ListTransactionsSuccessResponse(
result?.let {
ListTransactionsSuccessResponse.ListTransactionsResult(
transactions = it["transactions"]?.jsonArray?.mapNotNull { t -> parseTransaction(t.jsonObject) },
total_count = it["total_count"]?.jsonPrimitive?.longOrNull,
transactions = it.objectListOrNull("transactions")?.mapNotNull { t -> parseTransaction(t) },
total_count = it.longOrNull("total_count"),
)
},
)
}
private fun parseGetBalanceSuccess(json: JsonObject): GetBalanceSuccessResponse {
val result = json["result"]?.jsonObject
val result = json.objOrNull("result")
return GetBalanceSuccessResponse(
result?.let {
GetBalanceSuccessResponse.GetBalanceResult(
balance = it["balance"]?.jsonPrimitive?.longOrNull,
balance = it.longOrNull("balance"),
)
},
)
}
private fun parseGetInfoSuccess(json: JsonObject): GetInfoSuccessResponse {
val result = json["result"]?.jsonObject
val result = json.objOrNull("result")
return GetInfoSuccessResponse(
result?.let {
GetInfoSuccessResponse.GetInfoResult(
alias = it["alias"]?.jsonPrimitive?.content,
color = it["color"]?.jsonPrimitive?.content,
pubkey = it["pubkey"]?.jsonPrimitive?.content,
network = it["network"]?.jsonPrimitive?.content,
block_height = it["block_height"]?.jsonPrimitive?.longOrNull,
block_hash = it["block_hash"]?.jsonPrimitive?.content,
methods = it["methods"]?.jsonArray?.map { m -> m.jsonPrimitive.content },
notifications = it["notifications"]?.jsonArray?.map { n -> n.jsonPrimitive.content },
metadata = it["metadata"]?.jsonObject?.toAnyMap(),
lud16 = it["lud16"]?.jsonPrimitive?.content,
alias = it.stringOrNull("alias"),
color = it.stringOrNull("color"),
pubkey = it.stringOrNull("pubkey"),
network = it.stringOrNull("network"),
block_height = it.longOrNull("block_height"),
block_hash = it.stringOrNull("block_hash"),
methods = it.stringListOrNull("methods"),
notifications = it.stringListOrNull("notifications"),
metadata = it.anyMapOrNull("metadata"),
lud16 = it.stringOrNull("lud16"),
)
},
)
}
private fun parseGetBudgetSuccess(json: JsonObject): GetBudgetSuccessResponse {
val result = json["result"]?.jsonObject
val result = json.objOrNull("result")
return GetBudgetSuccessResponse(
result?.let {
GetBudgetSuccessResponse.GetBudgetResult(
used_budget = it["used_budget"]?.jsonPrimitive?.longOrNull,
total_budget = it["total_budget"]?.jsonPrimitive?.longOrNull,
renews_at = it["renews_at"]?.jsonPrimitive?.longOrNull,
renewal_period = it["renewal_period"]?.jsonPrimitive?.content,
used_budget = it.longOrNull("used_budget"),
total_budget = it.longOrNull("total_budget"),
renews_at = it.longOrNull("renews_at"),
renewal_period = it.stringOrNull("renewal_period"),
)
},
)
}
private fun parseSignMessageSuccess(json: JsonObject): SignMessageSuccessResponse {
val result = json["result"]?.jsonObject
val result = json.objOrNull("result")
return SignMessageSuccessResponse(
result?.let {
SignMessageSuccessResponse.SignMessageResult(
message = it["message"]?.jsonPrimitive?.content,
signature = it["signature"]?.jsonPrimitive?.content,
message = it.stringOrNull("message"),
signature = it.stringOrNull("signature"),
)
},
)
}
private fun parseCreateConnectionSuccess(json: JsonObject): CreateConnectionSuccessResponse {
val result = json["result"]?.jsonObject
val result = json.objOrNull("result")
return CreateConnectionSuccessResponse(
result?.let {
CreateConnectionSuccessResponse.CreateConnectionResult(
wallet_pubkey = it["wallet_pubkey"]?.jsonPrimitive?.content,
wallet_pubkey = it.stringOrNull("wallet_pubkey"),
)
},
)
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.RawJson
import com.vitorpamplona.quartz.nip01Core.core.isValid
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.toAnyValue
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
class NwcTransactionMetadata(
@@ -206,3 +206,20 @@ class CreateConnectionSuccessResponse(
val wallet_pubkey: String? = null,
)
}
/**
* A successful response whose `result_type` this build does not know.
*
* NIP-47 grows, and the wallet on the other end is somebody else's software: it may
* answer a method added after this release, or one from an extension we do not
* implement. Failing the whole parse would throw away a response that is perfectly
* well-formed and, for anything that reads [result] generically, perfectly usable —
* so the unrecognised ones arrive here with their result intact instead.
*
* It is deliberately NOT an [IErrorResponseLike]: an unknown answer is not a refusal,
* and code that branches on error must not treat it as one.
*/
class NwcUnknownResponse(
resultType: String,
val result: Map<String, Any?>? = null,
) : Response(resultType)
@@ -0,0 +1,123 @@
/*
* 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.experimental.clink
import com.vitorpamplona.quartz.experimental.clink.manage.ManageResponse
import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest
import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse
import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* The CLINK half of [com.vitorpamplona.quartz.nip47WalletConnect.Nip47MalformedInputTest]:
* a payment service is somebody else's software too, and CLINK is experimental, so its
* wire shapes are still moving.
*/
class ClinkMalformedInputTest {
private inline fun <reified T : OptimizedSerializable> parse(json: String): T = KotlinSerializationMapper.fromJsonTo<T>(json)
@Test
fun anOfferRequestParsesFromNothingButTheOffer() {
val req = parse<OfferRequest>("""{"offer":"lno1abc"}""")
assertEquals("lno1abc", req.offer)
assertNull(req.amount_sats)
assertNull(req.payer_data)
}
@Test
fun unknownFieldsAreIgnoredAndWrongShapesCostOnlyTheirField() {
val req =
parse<OfferRequest>(
"""{"offer":"lno1abc","amount_sats":{"not":"a number"},
"description":"dinner","invented_by_a_newer_service":[1,2]}""",
)
assertEquals("lno1abc", req.offer)
assertEquals("dinner", req.description)
assertNull(req.amount_sats)
}
@Test
fun amountsQuotedAsStringsStillParse() {
val req = parse<OfferRequest>("""{"offer":"lno1abc","amount_sats":"2500"}""")
assertEquals(2500L, req.amount_sats)
}
@Test
fun anErrorResponseSurvivesACodeSentAsAString() {
val res = parse<OfferResponse>("""{"error":"Invalid Amount","code":"5"}""")
assertEquals(5, res.code)
assertEquals("Invalid Amount", res.error)
assertTrue(!res.isSuccess())
}
@Test
fun aLoneDetailsObjectIsReadAsAOneElementList() {
// Jackson's ACCEPT_SINGLE_VALUE_AS_ARRAY was enabled for exactly this: a service
// answers with a bare object for one result and an array for several.
val res = parse<ManageResponse>("""{"res":"ok","resource":"offer","details":{"offer_id":"a1"}}""")
assertEquals(1, res.details?.size)
assertTrue(res.isOk())
}
@Test
fun aBrokenEntryInDetailsDoesNotLoseTheGoodOnes() {
val res =
parse<ManageResponse>(
"""{"res":"ok","resource":"offer","details":[{"offer_id":"a1"},"junk",{"offer_id":"a2"}]}""",
)
assertEquals(2, res.details?.size)
}
@Test
fun explicitNullsReadAsAbsent() {
val res = parse<OfferResponse>("""{"bolt11":null,"error":null,"code":null}""")
assertNull(res.bolt11)
assertNull(res.error)
assertNull(res.code)
}
@Test
fun anEmptyObjectIsNotAnException() {
val res = parse<OfferResponse>("""{}""")
assertNull(res.bolt11)
assertTrue(!res.isSuccess())
}
@Test
fun aRangeOfTheWrongShapeDoesNotFailTheResponse() {
val res = parse<OfferResponse>("""{"error":"Invalid Amount","code":5,"range":"1-100"}""")
assertEquals(5, res.code)
assertNull(res.range)
}
}
@@ -39,7 +39,7 @@ import kotlin.test.assertNull
* null, not the string "null", and must not crash on a null `metadata` object.
*
* Our own Jackson backend no longer emits those on request params — see
* [com.vitorpamplona.quartz.nip01Core.jackson.OmitNullsMixin] — but a third-party
* Jackson's OmitNullsMixin, now deleted along with that path — but a third-party
* wallet still may, so tolerating them on the way in remains required.
*/
class Nip47KotlinSerializationNullTest {
@@ -0,0 +1,185 @@
/*
* 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.nip47WalletConnect
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcUnknownResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
import kotlin.test.assertTrue
/**
* What a NIP-47 response parser has to survive, because the wallet on the other end
* is somebody else's software and NIP-47 keeps growing.
*
* The rule every case here asserts: one bad field costs you that field, never the
* message. Jackson used to bind these reflectively and answered a type mismatch with
* MismatchedInputException — a `fees_paid` of `"0"` instead of `0` threw away a
* settled payment's preimage.
*/
class Nip47MalformedInputTest {
private fun parse(json: String): Response = KotlinSerializationMapper.fromJsonTo<Response>(json)
@Test
fun unknownResultTypeKeepsItsResultInsteadOfFailing() {
// A method from a newer NIP-47, or an extension this build does not implement.
val res = parse("""{"result_type":"make_offer","result":{"offer":"lno1abc","amount":42}}""")
assertTrue(res is NwcUnknownResponse)
assertEquals("make_offer", res.resultType)
assertEquals("lno1abc", res.result?.get("offer"))
assertEquals(42L, res.result?.get("amount"))
}
@Test
fun unknownExtraFieldsAreIgnored() {
val res =
parse(
"""{"result_type":"pay_invoice","result":{"preimage":"abc","fees_paid":3,
"totally_new_field":{"nested":true}},"extra_root_field":[1,2,3]}""",
)
assertTrue(res is PayInvoiceSuccessResponse)
assertEquals("abc", res.result?.preimage)
assertEquals(3L, res.result?.fees_paid)
}
@Test
fun numbersSentAsStringsStillParse() {
// Several wallets quote their integers.
val res = parse("""{"result_type":"pay_invoice","result":{"preimage":"abc","fees_paid":"12"}}""")
assertTrue(res is PayInvoiceSuccessResponse)
assertEquals(12L, res.result?.fees_paid)
}
@Test
fun aFieldOfTheWrongShapeCostsOnlyThatField() {
// `preimage` arrives as an object. The payment still settled; read what is there.
val res =
parse(
"""{"result_type":"pay_invoice","result":{"preimage":{"unexpected":"object"},"fees_paid":7}}""",
)
assertTrue(res is PayInvoiceSuccessResponse)
assertNull(res.result?.preimage)
assertEquals(7L, res.result?.fees_paid)
}
@Test
fun explicitNullsReadTheSameAsAbsentKeys() {
val res = parse("""{"result_type":"pay_invoice","result":{"preimage":null,"fees_paid":null}}""")
assertTrue(res is PayInvoiceSuccessResponse)
assertNull(res.result?.preimage)
assertNull(res.result?.fees_paid)
}
@Test
fun aBrokenEntryDoesNotTakeDownTheWholeList() {
val res =
parse(
"""{"result_type":"list_transactions","result":{"transactions":[
{"type":"incoming","amount":1000},
"not-an-object",
{"type":"outgoing","amount":"2000"}]}}""",
)
assertTrue(res is ListTransactionsSuccessResponse)
val txs = res.result?.transactions
assertEquals(2, txs?.size)
assertEquals(1000L, txs?.get(0)?.amount)
assertEquals(2000L, txs?.get(1)?.amount)
}
@Test
fun aStringListDropsNonStringEntriesRatherThanFailing() {
val res =
parse(
"""{"result_type":"get_info","result":{"methods":["pay_invoice",{"bad":1},"get_balance"]}}""",
)
assertTrue(res is GetInfoSuccessResponse)
assertEquals(listOf("pay_invoice", "get_balance"), res.result?.methods)
}
@Test
fun aLoneValueIsAcceptedWhereAListIsExpected() {
// Jackson's ACCEPT_SINGLE_VALUE_AS_ARRAY, preserved.
val res = parse("""{"result_type":"get_info","result":{"methods":"pay_invoice"}}""")
assertTrue(res is GetInfoSuccessResponse)
assertEquals(listOf("pay_invoice"), res.result?.methods)
}
@Test
fun anErrorWithoutAMessageStillReportsItsCode() {
val res = parse("""{"result_type":"pay_invoice","error":{"code":"INSUFFICIENT_BALANCE"}}""")
assertTrue(res is com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike)
assertEquals("INSUFFICIENT_BALANCE", res.errorMessage())
}
@Test
fun anErrorWhoseCodeIsUnknownIsStillAnError() {
val res = parse("""{"result_type":"pay_invoice","error":{"code":"SOMETHING_NEW","message":"nope"}}""")
assertTrue(res is com.vitorpamplona.quartz.nip47WalletConnect.rpc.IErrorResponseLike)
assertEquals("nope", res.errorMessage())
}
@Test
fun anEmptyObjectIsNotAnException() {
val res = parse("""{}""")
assertTrue(res is NwcUnknownResponse)
assertEquals("", res.resultType)
}
@Test
fun anErrorTypedResponseSurvivesAnErrorFieldOfTheWrongShape() {
// `error` present but a string, not an object: still an error response.
val res = parse("""{"result_type":"pay_invoice","error":"boom"}""")
assertTrue(res is NwcErrorResponse || res is com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse)
}
@Test
fun aPayloadThatIsNotAnObjectFailsWithAClearMessage() {
// The one input still refused: there is no message to salvage from an array.
val thrown =
try {
parse("""["not","a","response"]""")
null
} catch (e: IllegalArgumentException) {
e
}
assertTrue(thrown != null, "an array root must be rejected")
assertTrue(thrown.message?.contains("must be a JSON object") == true, "got: ${thrown.message}")
}
}
@@ -21,11 +21,23 @@
package com.vitorpamplona.quartz.nip01Core.core
import com.fasterxml.jackson.databind.RuntimeJsonMappingException
import com.vitorpamplona.quartz.experimental.clink.debits.DebitRequest
import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse
import com.vitorpamplona.quartz.experimental.clink.manage.ManageRequest
import com.vitorpamplona.quartz.experimental.clink.manage.ManageResponse
import com.vitorpamplona.quartz.experimental.clink.offers.OfferReceipt
import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest
import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
import kotlinx.serialization.SerializationException
actual object OptimizedJsonMapper {
actual fun fromJson(json: String): Event =
@@ -81,16 +93,56 @@ actual object OptimizedJsonMapper {
actual fun toJson(tags: Array<Array<String>>): String = JacksonMapper.toJson(tags)
/**
* NIP-47 and CLINK read and write through kotlinx on every target, including this
* one. Two reasons, in order of importance:
*
* 1. These are the only types Jackson bound REFLECTIVELY here — the hand-written
* deserializers dispatched to the concrete classes with `treeToValue`. That made
* ~106 classes' field names load-bearing under R8 and cost two blanket keep rules.
* The kotlinx serializers name every field as a string literal, so nothing has to
* be kept.
* 2. Wallets and CLINK peers are other people's software. The kotlinx path reads a
* field of the wrong shape as absent instead of failing the message (see
* nip01Core.kotlinSerialization.LenientJson); Jackson raised
* MismatchedInputException and lost the whole response over one bad field.
*
* Everything else stays on Jackson, which is faster on the event hot path and is
* already non-reflective there.
*/
actual inline fun <reified T : OptimizedSerializable> fromJsonTo(json: String): T =
try {
JacksonMapper.fromJsonTo<T>(json)
} catch (e: com.fasterxml.jackson.core.JsonParseException) {
throw IllegalArgumentException(e.message, e)
} catch (e: com.fasterxml.jackson.core.JsonProcessingException) {
throw IllegalArgumentException(e.message, e)
} catch (e: RuntimeJsonMappingException) {
throw IllegalArgumentException(e.message, e)
when (T::class) {
Request::class, Response::class, Notification::class,
OfferRequest::class, OfferResponse::class, OfferReceipt::class,
DebitRequest::class, DebitResponse::class,
ManageRequest::class, ManageResponse::class,
->
try {
KotlinSerializationMapper.fromJsonTo<T>(json)
} catch (e: SerializationException) {
throw IllegalArgumentException(e.message, e)
}
else ->
try {
JacksonMapper.fromJsonTo<T>(json)
} catch (e: com.fasterxml.jackson.core.JsonParseException) {
throw IllegalArgumentException(e.message, e)
} catch (e: com.fasterxml.jackson.core.JsonProcessingException) {
throw IllegalArgumentException(e.message, e)
} catch (e: RuntimeJsonMappingException) {
throw IllegalArgumentException(e.message, e)
}
}
actual fun toJson(value: OptimizedSerializable): String = JacksonMapper.toJson(value)
actual fun toJson(value: OptimizedSerializable): String =
when (value) {
is Request, is Response, is Notification,
is OfferRequest, is OfferResponse, is OfferReceipt,
is DebitRequest, is DebitResponse,
is ManageRequest, is ManageResponse,
-> KotlinSerializationMapper.toJson(value)
else -> JacksonMapper.toJson(value)
}
}
@@ -53,28 +53,6 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerRequestDeseriali
import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerRequestSerializer
import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerResponseDeserializer
import com.vitorpamplona.quartz.nip46RemoteSigner.jackson.BunkerResponseSerializer
import com.vitorpamplona.quartz.nip47WalletConnect.jackson.NotificationDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.jackson.NotificationSerializer
import com.vitorpamplona.quartz.nip47WalletConnect.jackson.RequestDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.jackson.RequestSerializer
import com.vitorpamplona.quartz.nip47WalletConnect.jackson.ResponseDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.jackson.ResponseSerializer
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageParams
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.TlvRecord
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorDeserializer
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.jackson.RumorSerializer
@@ -114,31 +92,6 @@ class JacksonMapper {
// nip 59
.addSerializer(Rumor::class.java, RumorSerializer())
.addDeserializer(Rumor::class.java, RumorDeserializer())
// nip 47
.addSerializer(Response::class.java, ResponseSerializer())
.addDeserializer(Response::class.java, ResponseDeserializer())
.addSerializer(Request::class.java, RequestSerializer())
.addDeserializer(Request::class.java, RequestDeserializer())
.addSerializer(Notification::class.java, NotificationSerializer())
.addDeserializer(Notification::class.java, NotificationDeserializer())
// NIP-47's optional params are OMITTED when null — see OmitNullsMixin.
// Matches what the kotlinx backend has always done.
.setMixInAnnotation(PayInvoiceParams::class.java, OmitNullsMixin::class.java)
.setMixInAnnotation(PayParams::class.java, OmitNullsMixin::class.java)
.setMixInAnnotation(ReceiveParams::class.java, OmitNullsMixin::class.java)
.setMixInAnnotation(PayKeysendParams::class.java, OmitNullsMixin::class.java)
.setMixInAnnotation(MakeInvoiceParams::class.java, OmitNullsMixin::class.java)
.setMixInAnnotation(LookupInvoiceParams::class.java, OmitNullsMixin::class.java)
.setMixInAnnotation(ListTransactionsParams::class.java, OmitNullsMixin::class.java)
.setMixInAnnotation(MakeHoldInvoiceParams::class.java, OmitNullsMixin::class.java)
.setMixInAnnotation(CancelHoldInvoiceParams::class.java, OmitNullsMixin::class.java)
.setMixInAnnotation(SettleHoldInvoiceParams::class.java, OmitNullsMixin::class.java)
.setMixInAnnotation(SignMessageParams::class.java, OmitNullsMixin::class.java)
.setMixInAnnotation(CreateConnectionParams::class.java, OmitNullsMixin::class.java)
// NESTED, and the only params field that is not a primitive or an
// already-registered type: a TlvRecord inside pay_keysend's
// `tlv_records` has two independently optional fields of its own.
.setMixInAnnotation(TlvRecord::class.java, OmitNullsMixin::class.java)
// nip 46
.addDeserializer(BunkerMessage::class.java, BunkerMessageDeserializer())
.addSerializer(BunkerRequest::class.java, BunkerRequestSerializer())
@@ -1,42 +0,0 @@
/*
* 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.nip01Core.jackson
import com.fasterxml.jackson.annotation.JsonInclude
/**
* Applied to a reflectively-serialized DTO so Jackson OMITS a null field instead
* of writing it.
*
* Optional protocol fields are absent, not null. A peer is free to type one
* strictly: sending `"from": null` for an absent `from` earned
* `Invalid list_transactions params: from must be an integer` from a NIP-47
* wallet, and the request failed.
*
* A MIXIN rather than an annotation on the class, because these DTOs live in
* `commonMain` and Jackson annotations are JVM-only. Class-level rather than the
* mapper-wide `setSerializationInclusion`, which in Jackson 2.x also suppresses
* null MAP ENTRIES — and [com.vitorpamplona.quartz.nip01Core.kotlinSerialization.anyToJsonElement]
* deliberately keeps those, so a global setting would close one backend
* divergence by opening another.
*/
@JsonInclude(JsonInclude.Include.NON_NULL)
abstract class OmitNullsMixin
@@ -1,49 +0,0 @@
/*
* 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.nip47WalletConnect.jackson
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcNotificationType
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentSentNotification
import com.vitorpamplona.quartz.utils.asTextOrNull
class NotificationDeserializer : StdDeserializer<Notification>(Notification::class.java) {
override fun deserialize(
jp: JsonParser,
ctxt: DeserializationContext,
): Notification? {
val jsonObject: JsonNode = jp.codec.readTree(jp)
val notificationType = jsonObject.get("notification_type")?.asTextOrNull()
return when (notificationType) {
NwcNotificationType.PAYMENT_RECEIVED -> jp.codec.treeToValue(jsonObject, PaymentReceivedNotification::class.java)
NwcNotificationType.PAYMENT_SENT -> jp.codec.treeToValue(jsonObject, PaymentSentNotification::class.java)
NwcNotificationType.HOLD_INVOICE_ACCEPTED -> jp.codec.treeToValue(jsonObject, HoldInvoiceAcceptedNotification::class.java)
else -> null
}
}
}
@@ -1,60 +0,0 @@
/*
* 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.nip47WalletConnect.jackson
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.ser.std.StdSerializer
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.HoldInvoiceAcceptedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Notification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentReceivedNotification
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaymentSentNotification
class NotificationSerializer : StdSerializer<Notification>(Notification::class.java) {
override fun serialize(
value: Notification,
gen: JsonGenerator,
provider: SerializerProvider,
) {
gen.writeStartObject()
gen.writeStringField("notification_type", value.notification_type)
when (value) {
is PaymentReceivedNotification -> {
if (value.notification != null) {
gen.writeObjectField("notification", value.notification)
}
}
is PaymentSentNotification -> {
if (value.notification != null) {
gen.writeObjectField("notification", value.notification)
}
}
is HoldInvoiceAcceptedNotification -> {
if (value.notification != null) {
gen.writeObjectField("notification", value.notification)
}
}
}
gen.writeEndObject()
}
}
@@ -1,73 +0,0 @@
/*
* 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.nip47WalletConnect.jackson
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod
import com.vitorpamplona.quartz.utils.asTextOrNull
class RequestDeserializer : StdDeserializer<Request>(Request::class.java) {
override fun deserialize(
jp: JsonParser,
ctxt: DeserializationContext,
): Request? {
val jsonObject: JsonNode = jp.codec.readTree(jp)
val method = jsonObject.get("method")?.asTextOrNull()
return when (method) {
NwcMethod.PAY_INVOICE -> jp.codec.treeToValue(jsonObject, PayInvoiceMethod::class.java)
NwcMethod.PAY -> jp.codec.treeToValue(jsonObject, PayMethod::class.java)
NwcMethod.RECEIVE -> jp.codec.treeToValue(jsonObject, ReceiveMethod::class.java)
NwcMethod.PAY_KEYSEND -> jp.codec.treeToValue(jsonObject, PayKeysendMethod::class.java)
NwcMethod.MAKE_INVOICE -> jp.codec.treeToValue(jsonObject, MakeInvoiceMethod::class.java)
NwcMethod.LOOKUP_INVOICE -> jp.codec.treeToValue(jsonObject, LookupInvoiceMethod::class.java)
NwcMethod.LIST_TRANSACTIONS -> jp.codec.treeToValue(jsonObject, ListTransactionsMethod::class.java)
NwcMethod.GET_BALANCE -> jp.codec.treeToValue(jsonObject, GetBalanceMethod::class.java)
NwcMethod.GET_INFO -> jp.codec.treeToValue(jsonObject, GetInfoMethod::class.java)
NwcMethod.GET_BUDGET -> jp.codec.treeToValue(jsonObject, GetBudgetMethod::class.java)
NwcMethod.SIGN_MESSAGE -> jp.codec.treeToValue(jsonObject, SignMessageMethod::class.java)
NwcMethod.CREATE_CONNECTION -> jp.codec.treeToValue(jsonObject, CreateConnectionMethod::class.java)
NwcMethod.MAKE_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, MakeHoldInvoiceMethod::class.java)
NwcMethod.CANCEL_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, CancelHoldInvoiceMethod::class.java)
NwcMethod.SETTLE_HOLD_INVOICE -> jp.codec.treeToValue(jsonObject, SettleHoldInvoiceMethod::class.java)
else -> null
}
}
}
@@ -1,136 +0,0 @@
/*
* 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.nip47WalletConnect.jackson
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.ser.std.StdSerializer
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Request
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageMethod
class RequestSerializer : StdSerializer<Request>(Request::class.java) {
override fun serialize(
value: Request,
gen: JsonGenerator,
provider: SerializerProvider,
) {
gen.writeStartObject()
if (value.method != null) {
gen.writeStringField("method", value.method)
}
when (value) {
is PayInvoiceMethod -> {
if (value.params != null) {
gen.writeObjectField("params", value.params)
}
}
is PayMethod -> {
if (value.params != null) {
gen.writeObjectField("params", value.params)
}
}
is ReceiveMethod -> {
if (value.params != null) {
gen.writeObjectField("params", value.params)
}
}
is PayKeysendMethod -> {
if (value.params != null) {
gen.writeObjectField("params", value.params)
}
}
is MakeInvoiceMethod -> {
if (value.params != null) {
gen.writeObjectField("params", value.params)
}
}
is LookupInvoiceMethod -> {
if (value.params != null) {
gen.writeObjectField("params", value.params)
}
}
is ListTransactionsMethod -> {
if (value.params != null) {
gen.writeObjectField("params", value.params)
}
}
is MakeHoldInvoiceMethod -> {
if (value.params != null) {
gen.writeObjectField("params", value.params)
}
}
is CancelHoldInvoiceMethod -> {
if (value.params != null) {
gen.writeObjectField("params", value.params)
}
}
is SettleHoldInvoiceMethod -> {
if (value.params != null) {
gen.writeObjectField("params", value.params)
}
}
is SignMessageMethod -> {
if (value.params != null) {
gen.writeObjectField("params", value.params)
}
}
is CreateConnectionMethod -> {
if (value.params != null) {
gen.writeObjectField("params", value.params)
}
}
// Parameterless: `method` alone is the whole request. Spelled out rather
// than left to fall through, because Request is sealed and the compiler
// now makes every method state which of the two shapes it is.
is GetBalanceMethod,
is GetBudgetMethod,
is GetInfoMethod,
-> Unit
}
gen.writeEndObject()
}
}
@@ -1,146 +0,0 @@
/*
* 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.nip47WalletConnect.jackson
import com.fasterxml.jackson.core.JsonParser
import com.fasterxml.jackson.databind.DeserializationContext
import com.fasterxml.jackson.databind.JsonNode
import com.fasterxml.jackson.databind.deser.std.StdDeserializer
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcError
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse
import com.vitorpamplona.quartz.utils.asTextOrNull
class ResponseDeserializer : StdDeserializer<Response>(Response::class.java) {
override fun deserialize(
jp: JsonParser,
ctxt: DeserializationContext,
): Response? {
val jsonObject: JsonNode = jp.codec.readTree(jp)
val resultType = jsonObject.get("result_type")?.asTextOrNull()
val hasError = jsonObject.has("error") && !jsonObject.get("error").isNull
val hasResult = jsonObject.has("result") && !jsonObject.get("result").isNull
if (hasError) {
return when (resultType) {
NwcMethod.PAY_INVOICE -> {
jp.codec.treeToValue(jsonObject, PayInvoiceErrorResponse::class.java)
}
else -> {
val error = jp.codec.treeToValue(jsonObject.get("error"), NwcError::class.java)
NwcErrorResponse(resultType ?: "", error)
}
}
}
if (hasResult || resultType != null) {
return when (resultType) {
NwcMethod.PAY_INVOICE -> {
jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java)
}
NwcMethod.PAY -> {
jp.codec.treeToValue(jsonObject, PaySuccessResponse::class.java)
}
NwcMethod.RECEIVE -> {
jp.codec.treeToValue(jsonObject, ReceiveSuccessResponse::class.java)
}
NwcMethod.PAY_KEYSEND -> {
jp.codec.treeToValue(jsonObject, PayKeysendSuccessResponse::class.java)
}
NwcMethod.MAKE_INVOICE -> {
jp.codec.treeToValue(jsonObject, MakeInvoiceSuccessResponse::class.java)
}
NwcMethod.LOOKUP_INVOICE -> {
jp.codec.treeToValue(jsonObject, LookupInvoiceSuccessResponse::class.java)
}
NwcMethod.LIST_TRANSACTIONS -> {
jp.codec.treeToValue(jsonObject, ListTransactionsSuccessResponse::class.java)
}
NwcMethod.GET_BALANCE -> {
jp.codec.treeToValue(jsonObject, GetBalanceSuccessResponse::class.java)
}
NwcMethod.GET_INFO -> {
jp.codec.treeToValue(jsonObject, GetInfoSuccessResponse::class.java)
}
NwcMethod.GET_BUDGET -> {
jp.codec.treeToValue(jsonObject, GetBudgetSuccessResponse::class.java)
}
NwcMethod.SIGN_MESSAGE -> {
jp.codec.treeToValue(jsonObject, SignMessageSuccessResponse::class.java)
}
NwcMethod.CREATE_CONNECTION -> {
jp.codec.treeToValue(jsonObject, CreateConnectionSuccessResponse::class.java)
}
NwcMethod.MAKE_HOLD_INVOICE -> {
jp.codec.treeToValue(jsonObject, MakeHoldInvoiceSuccessResponse::class.java)
}
NwcMethod.CANCEL_HOLD_INVOICE -> {
jp.codec.treeToValue(jsonObject, CancelHoldInvoiceSuccessResponse::class.java)
}
NwcMethod.SETTLE_HOLD_INVOICE -> {
jp.codec.treeToValue(jsonObject, SettleHoldInvoiceSuccessResponse::class.java)
}
else -> {
// tries to guess for backward compatibility
if (jsonObject.get("result")?.get("preimage") != null) {
return jp.codec.treeToValue(jsonObject, PayInvoiceSuccessResponse::class.java)
}
null
}
}
}
return null
}
}
@@ -1,160 +0,0 @@
/*
* 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.nip47WalletConnect.jackson
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.ser.std.StdSerializer
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CancelHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.CreateConnectionSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBalanceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetBudgetSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.GetInfoSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.LookupInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.MakeInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceErrorResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PaySuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.ReceiveSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SettleHoldInvoiceSuccessResponse
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.SignMessageSuccessResponse
class ResponseSerializer : StdSerializer<Response>(Response::class.java) {
override fun serialize(
value: Response,
gen: JsonGenerator,
provider: SerializerProvider,
) {
gen.writeStartObject()
if (value.resultType.isNotEmpty()) {
gen.writeStringField("result_type", value.resultType)
}
when (value) {
is NwcErrorResponse -> {
if (value.error != null) {
gen.writeObjectField("error", value.error)
}
}
is PayInvoiceErrorResponse -> {
if (value.error != null) {
gen.writeObjectField("error", value.error)
}
}
is PayInvoiceSuccessResponse -> {
if (value.result != null) {
gen.writeObjectField("result", value.result)
}
}
is PaySuccessResponse -> {
if (value.result != null) {
gen.writeObjectField("result", value.result)
}
}
is ReceiveSuccessResponse -> {
if (value.result != null) {
gen.writeObjectField("result", value.result)
}
}
is PayKeysendSuccessResponse -> {
if (value.result != null) {
gen.writeObjectField("result", value.result)
}
}
is MakeInvoiceSuccessResponse -> {
if (value.result != null) {
gen.writeObjectField("result", value.result)
}
}
is LookupInvoiceSuccessResponse -> {
if (value.result != null) {
gen.writeObjectField("result", value.result)
}
}
is ListTransactionsSuccessResponse -> {
if (value.result != null) {
gen.writeObjectField("result", value.result)
}
}
is GetBalanceSuccessResponse -> {
if (value.result != null) {
gen.writeObjectField("result", value.result)
}
}
is GetInfoSuccessResponse -> {
if (value.result != null) {
gen.writeObjectField("result", value.result)
}
}
is MakeHoldInvoiceSuccessResponse -> {
if (value.result != null) {
gen.writeObjectField("result", value.result)
}
}
is CancelHoldInvoiceSuccessResponse -> {
if (value.result != null) {
gen.writeObjectField("result", value.result)
}
}
is SettleHoldInvoiceSuccessResponse -> {
if (value.result != null) {
gen.writeObjectField("result", value.result)
}
}
is GetBudgetSuccessResponse -> {
if (value.result != null) {
gen.writeObjectField("result", value.result)
}
}
is SignMessageSuccessResponse -> {
if (value.result != null) {
gen.writeObjectField("result", value.result)
}
}
is CreateConnectionSuccessResponse -> {
if (value.result != null) {
gen.writeObjectField("result", value.result)
}
}
}
gen.writeEndObject()
}
}
@@ -33,16 +33,20 @@ import com.vitorpamplona.quartz.experimental.clink.manage.OfferFields
import com.vitorpamplona.quartz.experimental.clink.offers.OfferReceipt
import com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest
import com.vitorpamplona.quartz.experimental.clink.offers.OfferResponse
import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper
import com.vitorpamplona.quartz.nip01Core.kotlinSerialization.KotlinSerializationMapper
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
/**
* Exercises the kotlinx-serialization path for the CLINK DTOs — the code path that
* native targets (iOS) use via `OptimizedJsonMapper` — and cross-checks it against
* Jackson (the JVM/Android path) so both backends agree on the wire shapes.
* Exercises the kotlinx-serialization path for the CLINK DTOs.
*
* It used to cross-check kotlinx against Jackson, because JVM/Android bound these
* reflectively while native used kotlinx. There is one backend now — [OptimizedJsonMapper]
* routes CLINK at kotlinx on every target — so the cross-check is instead that the
* serializers agree with the PLATFORM ENTRY POINT the app actually calls, which is what
* catches a routing mistake in OptimizedJsonMapper.jvmAndroid.
*/
class ClinkKotlinSerializationTest {
@Test
@@ -70,10 +74,10 @@ class ClinkKotlinSerializationTest {
assertEquals("A coffee", parsed.description)
// Jackson-serialized payload parses with kotlinx and vice versa.
val fromJackson = KotlinSerializationMapper.fromJsonTo<OfferRequest>(JacksonMapper.toJson(request))
assertEquals("coffee", fromJackson.offer)
val jacksonParsed = JacksonMapper.fromJsonTo<OfferRequest>(kotlinJson)
assertEquals(21000L, jacksonParsed.amount_sats)
val fromPlatform = KotlinSerializationMapper.fromJsonTo<OfferRequest>(OptimizedJsonMapper.toJson(request))
assertEquals("coffee", fromPlatform.offer)
val platformParsed = OptimizedJsonMapper.fromJsonTo<OfferRequest>(kotlinJson)
assertEquals(21000L, platformParsed.amount_sats)
}
@Test
@@ -160,8 +164,8 @@ class ClinkKotlinSerializationTest {
assertEquals(listOf("email", "name"), parsed.offer?.fields?.payer_data)
// Jackson reads the kotlinx output identically.
val jacksonParsed = JacksonMapper.fromJsonTo<ManageRequest>(json)
assertEquals("Coffee", jacksonParsed.offer?.fields?.label)
val platformParsed = OptimizedJsonMapper.fromJsonTo<ManageRequest>(json)
assertEquals("Coffee", platformParsed.offer?.fields?.label)
}
@Test
@@ -194,7 +198,7 @@ class ClinkKotlinSerializationTest {
assertEquals("o1", parsed.details?.first()?.id)
assertEquals(1500L, parsed.details?.first()?.price_sats)
val jacksonParsed = JacksonMapper.fromJsonTo<ManageResponse>(json)
assertEquals("noffer1...", jacksonParsed.details?.first()?.noffer)
val platformParsed = OptimizedJsonMapper.fromJsonTo<ManageResponse>(json)
assertEquals("noffer1...", platformParsed.details?.first()?.noffer)
}
}
+7 -21
View File
@@ -31,15 +31,10 @@
# WorkManager workers ALREADY GONE. androidx.work ships the keep
# itself; our duplicate rule was deleted and
# these entries now verify the library's.
# Jackson NWC + CLINK REMOVABLE, and the replacement already
# exists: hand-written KSerializers in
# commonMain cover exactly these types, are the
# production path on iOS, and are cross-checked
# against Jackson by ClinkKotlinSerializationTest
# and KotlinSerializationMapperTest. Routing
# jvmAndroid's fromJsonTo at
# KotlinSerializationMapper would delete both
# package keeps (~1,600 seeded members).
# Jackson NWC + CLINK DONE — both package keeps deleted. The types
# route at the hand-written kotlinx serializers
# on every target now, and the Jackson
# (de)serializers for them are gone.
# Jackson on-disk stores REMOVABLE. Plain data classes; @Serializable
# + kotlinx gives compile-time literal names.
# App-private files, so no interop risk.
@@ -79,18 +74,9 @@ class com.vitorpamplona.amethyst.service.notifications.NotificationCatchUpWorke
class com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
# --- Jackson reflective data binding: field names ARE the wire format ---------
# NIP-47 Wallet Connect, reached by treeToValue() from the Request/Response/
# Notification deserializers. Spot-checked rather than exhaustive: these four
# cover a params, a response, a nested type and an enum in the same package, so
# a rule that stops matching the package fails here.
fields com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceParams
fields com.vitorpamplona.quartz.nip47WalletConnect.rpc.ListTransactionsSuccessResponse
fields com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
fields com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcError
# CLINK, parsed with OptimizedJsonMapper.fromJsonTo<T>()
fields com.vitorpamplona.quartz.experimental.clink.offers.OfferRequest
fields com.vitorpamplona.quartz.experimental.clink.debits.DebitRequest
fields com.vitorpamplona.quartz.experimental.clink.manage.ManageRequest
# NIP-47 and CLINK used to be listed here. They are not data-bound reflectively
# any more — OptimizedJsonMapper routes them at kotlinx serializers that write
# every field name as a string literal — so there is no rule to verify.
# --- JSON the app writes to disk and reads back after an update --------------
# Field names are the file format; renaming them orphans every existing file.