From 475da3ff3c80b9670f234d69e0be2ebb4370eb53 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 19 Sep 2026 03:40:05 +0000 Subject: [PATCH] refactor: move NIP-47 and CLINK to kotlinx only, and make their parsers forgiving MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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() 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 Claude-Session: https://claude.ai/code/session_01DEoxktEZyTrAS33vVZBiwm --- amethyst/proguard-rules.pro | 13 +- quartz/consumer-rules.pro | 21 +- .../kotlinSerialization/ClinkKSerializers.kt | 60 +++--- .../kotlinSerialization/JsonAnyExt.kt | 31 +++ .../kotlinSerialization/LenientJson.kt | 143 ++++++++++++++ .../kotlinSerialization/JsonExt.kt | 48 ----- .../Nip47NotificationKSerializer.kt | 31 +-- .../Nip47RequestKSerializer.kt | 140 ++++++------- .../Nip47ResponseKSerializer.kt | 173 ++++++++-------- .../rpc/NwcTransactionMetadata.kt | 1 + .../quartz/nip47WalletConnect/rpc/Response.kt | 17 ++ .../clink/ClinkMalformedInputTest.kt | 123 ++++++++++++ .../Nip47KotlinSerializationNullTest.kt | 2 +- .../Nip47MalformedInputTest.kt | 185 ++++++++++++++++++ .../core/OptimizedJsonMapper.jvmAndroid.kt | 70 ++++++- .../quartz/nip01Core/jackson/JacksonMapper.kt | 47 ----- .../nip01Core/jackson/OmitNullsMixin.kt | 42 ---- .../jackson/NotificationDeserializer.kt | 49 ----- .../jackson/NotificationSerializer.kt | 60 ------ .../jackson/RequestDeserializer.kt | 73 ------- .../jackson/RequestSerializer.kt | 136 ------------- .../jackson/ResponseDeserializer.kt | 146 -------------- .../jackson/ResponseSerializer.kt | 160 --------------- .../clink/ClinkKotlinSerializationTest.kt | 28 +-- tools/r8-verify/reflection-contract.txt | 28 +-- 25 files changed, 807 insertions(+), 1020 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LenientJson.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/JsonExt.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkMalformedInputTest.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47MalformedInputTest.kt delete mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/OmitNullsMixin.kt delete mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationDeserializer.kt delete mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationSerializer.kt delete mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt delete mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt delete mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt delete mode 100644 quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt diff --git a/amethyst/proguard-rules.pro b/amethyst/proguard-rules.pro index 141a6a7d5e..f374b857ef 100644 --- a/amethyst/proguard-rules.pro +++ b/amethyst/proguard-rules.pro @@ -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() 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. diff --git a/quartz/consumer-rules.pro b/quartz/consumer-rules.pro index ec562e366e..a072dd5bc4 100644 --- a/quartz/consumer-rules.pro +++ b/quartz/consumer-rules.pro @@ -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()`, 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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/kotlinSerialization/ClinkKSerializers.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/kotlinSerialization/ClinkKSerializers.kt index 6e2749cc3d..8cf25bc1d9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/kotlinSerialization/ClinkKSerializers.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/kotlinSerialization/ClinkKSerializers.kt @@ -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? = (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 { } 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 { } 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 { } 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 { } 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 { } 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 { } 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 { 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 { } 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 { 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) }, ) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/JsonAnyExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/JsonAnyExt.kt index fc1a6be6b7..254c9356d4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/JsonAnyExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/JsonAnyExt.kt @@ -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 = entries.associate { it.key to it.value.toAnyValue() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LenientJson.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LenientJson.kt new file mode 100644 index 0000000000..646452e96f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/LenientJson.kt @@ -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? = 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? = objOrNull(key)?.toAnyMap() + +/** Every object in the array, skipping entries that are not objects. */ +fun JsonObject.objectListOrNull(key: String): List? = 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()}") +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/JsonExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/JsonExt.kt deleted file mode 100644 index 472972cb6e..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/JsonExt.kt +++ /dev/null @@ -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 = entries.associate { it.key to it.value.toAnyValue() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt index e3588f77d5..527e8f235d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47NotificationKSerializer.kt @@ -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 { 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 { 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"), ) }, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt index 8f281ebc4b..f0f1a16251 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt @@ -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 { 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 { } 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 { } 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"), ) }, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt index ded0e24008..92ee8668cd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt @@ -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 { 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 { 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 { } 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 { } 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 { } 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 { 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 { 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"), ) }, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt index 7d90f3b256..bfa0c64788 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt @@ -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( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt index b109074f68..6144cfdd47 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/Response.kt @@ -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? = null, +) : Response(resultType) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkMalformedInputTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkMalformedInputTest.kt new file mode 100644 index 0000000000..57c479c808 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkMalformedInputTest.kt @@ -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 parse(json: String): T = KotlinSerializationMapper.fromJsonTo(json) + + @Test + fun anOfferRequestParsesFromNothingButTheOffer() { + val req = parse("""{"offer":"lno1abc"}""") + + assertEquals("lno1abc", req.offer) + assertNull(req.amount_sats) + assertNull(req.payer_data) + } + + @Test + fun unknownFieldsAreIgnoredAndWrongShapesCostOnlyTheirField() { + val req = + parse( + """{"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("""{"offer":"lno1abc","amount_sats":"2500"}""") + + assertEquals(2500L, req.amount_sats) + } + + @Test + fun anErrorResponseSurvivesACodeSentAsAString() { + val res = parse("""{"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("""{"res":"ok","resource":"offer","details":{"offer_id":"a1"}}""") + + assertEquals(1, res.details?.size) + assertTrue(res.isOk()) + } + + @Test + fun aBrokenEntryInDetailsDoesNotLoseTheGoodOnes() { + val res = + parse( + """{"res":"ok","resource":"offer","details":[{"offer_id":"a1"},"junk",{"offer_id":"a2"}]}""", + ) + + assertEquals(2, res.details?.size) + } + + @Test + fun explicitNullsReadAsAbsent() { + val res = parse("""{"bolt11":null,"error":null,"code":null}""") + + assertNull(res.bolt11) + assertNull(res.error) + assertNull(res.code) + } + + @Test + fun anEmptyObjectIsNotAnException() { + val res = parse("""{}""") + + assertNull(res.bolt11) + assertTrue(!res.isSuccess()) + } + + @Test + fun aRangeOfTheWrongShapeDoesNotFailTheResponse() { + val res = parse("""{"error":"Invalid Amount","code":5,"range":"1-100"}""") + + assertEquals(5, res.code) + assertNull(res.range) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47KotlinSerializationNullTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47KotlinSerializationNullTest.kt index 6e25c9bc33..d3d5078a3f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47KotlinSerializationNullTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47KotlinSerializationNullTest.kt @@ -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 { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47MalformedInputTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47MalformedInputTest.kt new file mode 100644 index 0000000000..3373492a03 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/Nip47MalformedInputTest.kt @@ -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(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}") + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.jvmAndroid.kt index 89a3287d63..58f50ad509 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.jvmAndroid.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/core/OptimizedJsonMapper.jvmAndroid.kt @@ -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>): 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 fromJsonTo(json: String): T = - try { - JacksonMapper.fromJsonTo(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(json) + } catch (e: SerializationException) { + throw IllegalArgumentException(e.message, e) + } + + else -> + try { + JacksonMapper.fromJsonTo(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) + } } diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt index 0c4365031e..acf1711813 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt @@ -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()) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/OmitNullsMixin.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/OmitNullsMixin.kt deleted file mode 100644 index 17f4b976aa..0000000000 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/OmitNullsMixin.kt +++ /dev/null @@ -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 diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationDeserializer.kt deleted file mode 100644 index 70535bb019..0000000000 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationDeserializer.kt +++ /dev/null @@ -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::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 - } - } -} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationSerializer.kt deleted file mode 100644 index 6d7376fbfe..0000000000 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/NotificationSerializer.kt +++ /dev/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::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() - } -} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt deleted file mode 100644 index 27ec2cc38f..0000000000 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestDeserializer.kt +++ /dev/null @@ -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::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 - } - } -} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt deleted file mode 100644 index 8bb35de4f3..0000000000 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/RequestSerializer.kt +++ /dev/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::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() - } -} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt deleted file mode 100644 index 8c503e7140..0000000000 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseDeserializer.kt +++ /dev/null @@ -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::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 - } -} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt deleted file mode 100644 index 99bc7613b8..0000000000 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/jackson/ResponseSerializer.kt +++ /dev/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::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() - } -} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkKotlinSerializationTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkKotlinSerializationTest.kt index 40a25246dc..2cf75ec219 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkKotlinSerializationTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkKotlinSerializationTest.kt @@ -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(JacksonMapper.toJson(request)) - assertEquals("coffee", fromJackson.offer) - val jacksonParsed = JacksonMapper.fromJsonTo(kotlinJson) - assertEquals(21000L, jacksonParsed.amount_sats) + val fromPlatform = KotlinSerializationMapper.fromJsonTo(OptimizedJsonMapper.toJson(request)) + assertEquals("coffee", fromPlatform.offer) + val platformParsed = OptimizedJsonMapper.fromJsonTo(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(json) - assertEquals("Coffee", jacksonParsed.offer?.fields?.label) + val platformParsed = OptimizedJsonMapper.fromJsonTo(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(json) - assertEquals("noffer1...", jacksonParsed.details?.first()?.noffer) + val platformParsed = OptimizedJsonMapper.fromJsonTo(json) + assertEquals("noffer1...", platformParsed.details?.first()?.noffer) } } diff --git a/tools/r8-verify/reflection-contract.txt b/tools/r8-verify/reflection-contract.txt index 747313590f..c54d2091c8 100644 --- a/tools/r8-verify/reflection-contract.txt +++ b/tools/r8-verify/reflection-contract.txt @@ -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() -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.