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