diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/kotlinSerialization/ClinkKSerializers.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/kotlinSerialization/ClinkKSerializers.kt new file mode 100644 index 0000000000..177f6acca2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/clink/kotlinSerialization/ClinkKSerializers.kt @@ -0,0 +1,399 @@ +/* + * 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.kotlinSerialization + +import com.vitorpamplona.quartz.experimental.clink.common.GfyDelta +import com.vitorpamplona.quartz.experimental.clink.common.SatRange +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency +import com.vitorpamplona.quartz.experimental.clink.debits.DebitRequest +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.manage.ManageOffer +import com.vitorpamplona.quartz.experimental.clink.manage.ManageRequest +import com.vitorpamplona.quartz.experimental.clink.manage.ManageResponse +import com.vitorpamplona.quartz.experimental.clink.manage.OfferData +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.nip47WalletConnect.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 +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.add +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 + +/** + * Hand-written kotlinx serializers for the CLINK decrypted payload DTOs + * (`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. + */ + +private fun anyToJsonElement(value: Any?): JsonElement = + when (value) { + null -> JsonNull + is JsonElement -> value + is String -> JsonPrimitive(value) + is Boolean -> JsonPrimitive(value) + is Number -> JsonPrimitive(value) + is Map<*, *> -> buildJsonObject { value.forEach { (k, v) -> put(k.toString(), anyToJsonElement(v)) } } + is Iterable<*> -> buildJsonArray { value.forEach { add(anyToJsonElement(it)) } } + else -> JsonPrimitive(value.toString()) + } + +private fun JsonObject.stringOrNull(key: String): String? = get(key)?.let { if (it is JsonNull) null else it.jsonPrimitive.content } + +private fun JsonObject.longOrNull(key: String): Long? = get(key)?.let { if (it is JsonNull) null else it.jsonPrimitive.longOrNull } + +private fun JsonObject.intOrNull(key: String): Int? = get(key)?.let { if (it is JsonNull) null else it.jsonPrimitive.intOrNull } + +private fun JsonObject.objectOrNull(key: String): JsonObject? = get(key) as? JsonObject + +private fun JsonObject.stringListOrNull(key: String): List? = (get(key) as? JsonArray)?.map { it.jsonPrimitive.content } + +private fun serializeSatRange(range: SatRange): JsonObject = + buildJsonObject { + range.min?.let { put("min", it) } + range.max?.let { put("max", it) } + } + +private fun parseSatRange(obj: JsonObject): SatRange = + SatRange( + min = obj.longOrNull("min"), + max = obj.longOrNull("max"), + ) + +private fun serializeGfyDelta(delta: GfyDelta): JsonObject = + buildJsonObject { + delta.max_delta_ms?.let { put("max_delta_ms", it) } + delta.actual_delta_ms?.let { put("actual_delta_ms", it) } + } + +private fun parseGfyDelta(obj: JsonObject): GfyDelta = + GfyDelta( + max_delta_ms = obj.longOrNull("max_delta_ms"), + actual_delta_ms = obj.longOrNull("actual_delta_ms"), + ) + +object OfferRequestKSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ClinkOfferRequest") + + override fun serialize( + encoder: Encoder, + value: OfferRequest, + ) { + val jsonObject = + buildJsonObject { + value.offer?.let { put("offer", it) } + value.amount_sats?.let { put("amount_sats", it) } + value.payer_data?.let { put("payer_data", anyToJsonElement(it)) } + value.zap?.let { put("zap", it) } + value.expires_in_seconds?.let { put("expires_in_seconds", it) } + value.description?.let { put("description", it) } + } + (encoder as JsonEncoder).encodeJsonElement(jsonObject) + } + + override fun deserialize(decoder: Decoder): OfferRequest { + val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject + return OfferRequest( + offer = obj.stringOrNull("offer"), + amount_sats = obj.longOrNull("amount_sats"), + payer_data = obj.objectOrNull("payer_data")?.toAnyMap(), + zap = obj.stringOrNull("zap"), + expires_in_seconds = obj.longOrNull("expires_in_seconds"), + description = obj.stringOrNull("description"), + ) + } +} + +object OfferResponseKSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ClinkOfferResponse") + + override fun serialize( + encoder: Encoder, + value: OfferResponse, + ) { + val jsonObject = + buildJsonObject { + value.bolt11?.let { put("bolt11", it) } + value.error?.let { put("error", it) } + value.code?.let { put("code", it) } + value.range?.let { put("range", serializeSatRange(it)) } + value.latest?.let { put("latest", it) } + } + (encoder as JsonEncoder).encodeJsonElement(jsonObject) + } + + override fun deserialize(decoder: Decoder): OfferResponse { + val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject + return OfferResponse( + bolt11 = obj.stringOrNull("bolt11"), + error = obj.stringOrNull("error"), + code = obj.intOrNull("code"), + range = obj.objectOrNull("range")?.let { parseSatRange(it) }, + latest = obj.stringOrNull("latest"), + ) + } +} + +object OfferReceiptKSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ClinkOfferReceipt") + + override fun serialize( + encoder: Encoder, + value: OfferReceipt, + ) { + val jsonObject = + buildJsonObject { + value.res?.let { put("res", it) } + value.preimage?.let { put("preimage", it) } + } + (encoder as JsonEncoder).encodeJsonElement(jsonObject) + } + + override fun deserialize(decoder: Decoder): OfferReceipt { + val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject + return OfferReceipt( + res = obj.stringOrNull("res"), + preimage = obj.stringOrNull("preimage"), + ) + } +} + +object DebitRequestKSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ClinkDebitRequest") + + override fun serialize( + encoder: Encoder, + value: DebitRequest, + ) { + val jsonObject = + buildJsonObject { + value.pointer?.let { put("pointer", it) } + value.amount_sats?.let { put("amount_sats", it) } + value.bolt11?.let { put("bolt11", it) } + value.description?.let { put("description", it) } + value.k1?.let { put("k1", it) } + value.frequency?.let { put("frequency", serializeDebitFrequency(it)) } + } + (encoder as JsonEncoder).encodeJsonElement(jsonObject) + } + + override fun deserialize(decoder: Decoder): DebitRequest { + val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject + 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) }, + ) + } + + private fun serializeDebitFrequency(frequency: DebitFrequency): JsonObject = + buildJsonObject { + frequency.number?.let { put("number", it) } + frequency.unit?.let { put("unit", it) } + } + + private fun parseDebitFrequency(obj: JsonObject): DebitFrequency = + DebitFrequency( + number = obj.intOrNull("number"), + unit = obj.stringOrNull("unit"), + ) +} + +object DebitResponseKSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ClinkDebitResponse") + + override fun serialize( + encoder: Encoder, + value: DebitResponse, + ) { + val jsonObject = + buildJsonObject { + value.res?.let { put("res", it) } + value.preimage?.let { put("preimage", it) } + value.code?.let { put("code", it) } + value.error?.let { put("error", it) } + value.range?.let { put("range", serializeSatRange(it)) } + value.retry_after?.let { put("retry_after", it) } + value.delta?.let { put("delta", serializeGfyDelta(it)) } + } + (encoder as JsonEncoder).encodeJsonElement(jsonObject) + } + + override fun deserialize(decoder: Decoder): DebitResponse { + val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject + 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) }, + retry_after = obj.longOrNull("retry_after"), + delta = obj.objectOrNull("delta")?.let { parseGfyDelta(it) }, + ) + } +} + +object ManageRequestKSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ClinkManageRequest") + + override fun serialize( + encoder: Encoder, + value: ManageRequest, + ) { + val jsonObject = + buildJsonObject { + value.resource?.let { put("resource", it) } + value.action?.let { put("action", it) } + value.pointer?.let { put("pointer", it) } + value.offer?.let { put("offer", serializeManageOffer(it)) } + } + (encoder as JsonEncoder).encodeJsonElement(jsonObject) + } + + override fun deserialize(decoder: Decoder): ManageRequest { + val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject + return ManageRequest( + resource = obj.stringOrNull("resource"), + action = obj.stringOrNull("action"), + pointer = obj.stringOrNull("pointer"), + offer = obj.objectOrNull("offer")?.let { parseManageOffer(it) }, + ) + } + + private fun serializeManageOffer(offer: ManageOffer): JsonObject = + buildJsonObject { + offer.id?.let { put("id", it) } + offer.fields?.let { put("fields", serializeOfferFields(it)) } + } + + private fun parseManageOffer(obj: JsonObject): ManageOffer = + ManageOffer( + id = obj.stringOrNull("id"), + fields = obj.objectOrNull("fields")?.let { parseOfferFields(it) }, + ) + + private fun serializeOfferFields(fields: OfferFields): JsonObject = + buildJsonObject { + fields.label?.let { put("label", it) } + fields.price_sats?.let { put("price_sats", it) } + fields.callback_url?.let { put("callback_url", it) } + fields.payer_data?.let { names -> put("payer_data", buildJsonArray { names.forEach { add(it) } }) } + } + + private fun parseOfferFields(obj: JsonObject): OfferFields = + OfferFields( + label = obj.stringOrNull("label"), + price_sats = obj.longOrNull("price_sats"), + callback_url = obj.stringOrNull("callback_url"), + payer_data = obj.stringListOrNull("payer_data"), + ) +} + +object ManageResponseKSerializer : KSerializer { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("ClinkManageResponse") + + override fun serialize( + encoder: Encoder, + value: ManageResponse, + ) { + val jsonObject = + buildJsonObject { + value.res?.let { put("res", it) } + value.resource?.let { put("resource", it) } + value.details?.let { details -> + put("details", buildJsonArray { details.forEach { add(serializeOfferData(it)) } }) + } + value.code?.let { put("code", it) } + value.error?.let { put("error", it) } + value.field?.let { put("field", it) } + value.range?.let { put("range", serializeSatRange(it)) } + value.retry_after?.let { put("retry_after", it) } + value.delta?.let { put("delta", serializeGfyDelta(it)) } + } + (encoder as JsonEncoder).encodeJsonElement(jsonObject) + } + + override fun deserialize(decoder: Decoder): ManageResponse { + val obj = (decoder as JsonDecoder).decodeJsonElement().jsonObject + return ManageResponse( + res = obj.stringOrNull("res"), + resource = obj.stringOrNull("resource"), + details = parseDetails(obj["details"]), + code = obj.intOrNull("code"), + error = obj.stringOrNull("error"), + field = obj.stringOrNull("field"), + range = obj.objectOrNull("range")?.let { parseSatRange(it) }, + retry_after = obj.longOrNull("retry_after"), + delta = obj.objectOrNull("delta")?.let { parseGfyDelta(it) }, + ) + } + + /** The spec types `details` as `OfferData | OfferData[]`: coerce a lone object into a one-element list. */ + private fun parseDetails(element: JsonElement?): List? = + when (element) { + null, is JsonNull -> null + is JsonArray -> element.mapNotNull { (it as? JsonObject)?.let { obj -> parseOfferData(obj) } } + is JsonObject -> listOf(parseOfferData(element)) + else -> null + } + + private fun serializeOfferData(data: OfferData): JsonObject = + buildJsonObject { + data.id?.let { put("id", it) } + data.noffer?.let { put("noffer", it) } + data.label?.let { put("label", it) } + data.price_sats?.let { put("price_sats", it) } + data.callback_url?.let { put("callback_url", it) } + data.payer_data?.let { names -> put("payer_data", buildJsonArray { names.forEach { add(it) } }) } + } + + private fun parseOfferData(obj: JsonObject): OfferData = + OfferData( + id = obj.stringOrNull("id"), + noffer = obj.stringOrNull("noffer"), + label = obj.stringOrNull("label"), + price_sats = obj.longOrNull("price_sats"), + callback_url = obj.stringOrNull("callback_url"), + payer_data = obj.stringListOrNull("payer_data"), + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt index 93caff876e..556ed1d4af 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt @@ -20,6 +20,20 @@ */ package com.vitorpamplona.quartz.nip01Core.kotlinSerialization +import com.vitorpamplona.quartz.experimental.clink.debits.DebitRequest +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.kotlinSerialization.DebitRequestKSerializer +import com.vitorpamplona.quartz.experimental.clink.kotlinSerialization.DebitResponseKSerializer +import com.vitorpamplona.quartz.experimental.clink.kotlinSerialization.ManageRequestKSerializer +import com.vitorpamplona.quartz.experimental.clink.kotlinSerialization.ManageResponseKSerializer +import com.vitorpamplona.quartz.experimental.clink.kotlinSerialization.OfferReceiptKSerializer +import com.vitorpamplona.quartz.experimental.clink.kotlinSerialization.OfferRequestKSerializer +import com.vitorpamplona.quartz.experimental.clink.kotlinSerialization.OfferResponseKSerializer +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.core.Event import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable import com.vitorpamplona.quartz.nip01Core.core.TagArray @@ -121,6 +135,34 @@ class KotlinSerializationMapper { json.encodeToString(Nip47NotificationKSerializer, value) } + is OfferRequest -> { + json.encodeToString(OfferRequestKSerializer, value) + } + + is OfferResponse -> { + json.encodeToString(OfferResponseKSerializer, value) + } + + is OfferReceipt -> { + json.encodeToString(OfferReceiptKSerializer, value) + } + + is DebitRequest -> { + json.encodeToString(DebitRequestKSerializer, value) + } + + is DebitResponse -> { + json.encodeToString(DebitResponseKSerializer, value) + } + + is ManageRequest -> { + json.encodeToString(ManageRequestKSerializer, value) + } + + is ManageResponse -> { + json.encodeToString(ManageResponseKSerializer, value) + } + else -> { throw IllegalArgumentException("Unsupported type: ${value::class}") } @@ -141,6 +183,13 @@ class KotlinSerializationMapper { Response::class -> json.decodeFromString(Nip47ResponseKSerializer, jsonStr) Request::class -> json.decodeFromString(Nip47RequestKSerializer, jsonStr) Notification::class -> json.decodeFromString(Nip47NotificationKSerializer, jsonStr) + OfferRequest::class -> json.decodeFromString(OfferRequestKSerializer, jsonStr) + OfferResponse::class -> json.decodeFromString(OfferResponseKSerializer, jsonStr) + OfferReceipt::class -> json.decodeFromString(OfferReceiptKSerializer, jsonStr) + DebitRequest::class -> json.decodeFromString(DebitRequestKSerializer, jsonStr) + DebitResponse::class -> json.decodeFromString(DebitResponseKSerializer, jsonStr) + ManageRequest::class -> json.decodeFromString(ManageRequestKSerializer, jsonStr) + ManageResponse::class -> json.decodeFromString(ManageResponseKSerializer, jsonStr) else -> throw IllegalArgumentException("Unsupported type: ${T::class}") } @Suppress("UNCHECKED_CAST") diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkKotlinSerializationTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkKotlinSerializationTest.kt new file mode 100644 index 0000000000..40a25246dc --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/experimental/clink/ClinkKotlinSerializationTest.kt @@ -0,0 +1,200 @@ +/* + * 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.common.GfyDelta +import com.vitorpamplona.quartz.experimental.clink.common.SatRange +import com.vitorpamplona.quartz.experimental.clink.debits.DebitFrequency +import com.vitorpamplona.quartz.experimental.clink.debits.DebitRequest +import com.vitorpamplona.quartz.experimental.clink.debits.DebitResponse +import com.vitorpamplona.quartz.experimental.clink.manage.ManageOffer +import com.vitorpamplona.quartz.experimental.clink.manage.ManageRequest +import com.vitorpamplona.quartz.experimental.clink.manage.ManageResponse +import com.vitorpamplona.quartz.experimental.clink.manage.OfferData +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.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. + */ +class ClinkKotlinSerializationTest { + @Test + fun offerRequestRoundTripAndCrossParse() { + val request = + OfferRequest( + offer = "coffee", + amount_sats = 21000, + payer_data = mapOf("email" to "a@b.c", "count" to 2L), + zap = "{...}", + expires_in_seconds = 3600, + description = "A coffee", + ) + + val kotlinJson = KotlinSerializationMapper.toJson(request) + val parsed = KotlinSerializationMapper.fromJsonTo(kotlinJson) + assertEquals("coffee", parsed.offer) + assertEquals(21000L, parsed.amount_sats) + assertEquals("a@b.c", parsed.payer_data?.get("email")) + // payer_data is an opaque pass-through map; numeric boxing differs between + // backends (Jackson: Int, kotlinx via toAnyValue(): Double), so compare values. + assertEquals(2L, (parsed.payer_data?.get("count") as Number).toLong()) + assertEquals("{...}", parsed.zap) + assertEquals(3600L, parsed.expires_in_seconds) + assertEquals("A coffee", parsed.description) + + // Jackson-serialized payload parses with kotlinx and vice versa. + val fromJackson = KotlinSerializationMapper.fromJsonTo(JacksonMapper.toJson(request)) + assertEquals("coffee", fromJackson.offer) + val jacksonParsed = JacksonMapper.fromJsonTo(kotlinJson) + assertEquals(21000L, jacksonParsed.amount_sats) + } + + @Test + fun offerResponseParsesRangeAndLatest() { + val parsed = + KotlinSerializationMapper.fromJsonTo( + """{"error":"Invalid Amount","code":5,"range":{"min":1000,"max":50000},"latest":"noffer1qqsmoved"}""", + ) + assertEquals(5, parsed.code) + assertEquals(1000L, parsed.range?.min) + assertEquals(50000L, parsed.range?.max) + assertEquals("noffer1qqsmoved", parsed.latest) + + val success = KotlinSerializationMapper.fromJsonTo("""{"bolt11":"lnbc1..."}""") + assertTrue(success.isSuccess()) + assertEquals( + "lnbc1...", + KotlinSerializationMapper.fromJsonTo(KotlinSerializationMapper.toJson(success)).bolt11, + ) + } + + @Test + fun offerReceiptRoundTrip() { + val receipt = OfferReceipt(res = OfferReceipt.OK, preimage = "ab".repeat(32)) + val parsed = KotlinSerializationMapper.fromJsonTo(KotlinSerializationMapper.toJson(receipt)) + assertTrue(parsed.isOk()) + assertEquals("ab".repeat(32), parsed.preimage) + } + + @Test + fun debitRequestRoundTripWithFrequency() { + val request = + DebitRequest( + pointer = "ndebit1...", + amount_sats = 1000, + description = "budget", + k1 = "4caa9ee5", + frequency = DebitFrequency(1, DebitFrequency.UNIT_MONTH), + ) + val parsed = KotlinSerializationMapper.fromJsonTo(KotlinSerializationMapper.toJson(request)) + assertEquals("ndebit1...", parsed.pointer) + assertEquals(1000L, parsed.amount_sats) + assertEquals("4caa9ee5", parsed.k1) + assertEquals(1, parsed.frequency?.number) + assertEquals(DebitFrequency.UNIT_MONTH, parsed.frequency?.unit) + } + + @Test + fun debitResponseParsesGfyExtras() { + val response = + DebitResponse( + res = DebitResponse.GFY, + code = 3, + error = "Expired Request", + range = SatRange(10, 10000000), + retry_after = 1717000000, + delta = GfyDelta(30000, 45000), + ) + val parsed = KotlinSerializationMapper.fromJsonTo(KotlinSerializationMapper.toJson(response)) + assertEquals(3, parsed.code) + assertEquals(10L, parsed.range?.min) + assertEquals(1717000000L, parsed.retry_after) + assertEquals(30000L, parsed.delta?.max_delta_ms) + assertEquals(45000L, parsed.delta?.actual_delta_ms) + } + + @Test + fun manageRequestRoundTripsNestedOfferFields() { + val request = + ManageRequest( + resource = ManageRequest.RESOURCE_OFFER, + action = ManageRequest.ACTION_UPDATE, + offer = + ManageOffer( + id = "offer-1", + fields = OfferFields("Coffee", 1500L, "https://x/cb", listOf("email", "name")), + ), + ) + val json = KotlinSerializationMapper.toJson(request) + val parsed = KotlinSerializationMapper.fromJsonTo(json) + assertEquals("offer-1", parsed.offer?.id) + assertEquals("Coffee", parsed.offer?.fields?.label) + assertEquals(1500L, parsed.offer?.fields?.price_sats) + assertEquals(listOf("email", "name"), parsed.offer?.fields?.payer_data) + + // Jackson reads the kotlinx output identically. + val jacksonParsed = JacksonMapper.fromJsonTo(json) + assertEquals("Coffee", jacksonParsed.offer?.fields?.label) + } + + @Test + fun manageResponseCoercesSingleDetailsObjectToList() { + val single = + KotlinSerializationMapper.fromJsonTo( + """{"res":"ok","resource":"offer","details":{"id":"o1","noffer":"noffer1...","label":"Coffee","price_sats":1500}}""", + ) + assertEquals(1, single.details?.size) + assertEquals("o1", single.details?.first()?.id) + + val array = + KotlinSerializationMapper.fromJsonTo( + """{"res":"ok","resource":"offer","details":[{"id":"o1"},{"id":"o2"}]}""", + ) + assertEquals(listOf("o1", "o2"), array.details?.map { it.id }) + } + + @Test + fun manageResponseRoundTripWithDetails() { + val response = + ManageResponse( + res = ManageResponse.OK, + resource = "offer", + details = listOf(OfferData(id = "o1", noffer = "noffer1...", label = "Coffee", price_sats = 1500)), + ) + val json = KotlinSerializationMapper.toJson(response) + val parsed = KotlinSerializationMapper.fromJsonTo(json) + assertTrue(parsed.isOk()) + assertEquals("o1", parsed.details?.first()?.id) + assertEquals(1500L, parsed.details?.first()?.price_sats) + + val jacksonParsed = JacksonMapper.fromJsonTo(json) + assertEquals("noffer1...", jacksonParsed.details?.first()?.noffer) + } +}