From ffac6822380c663ff798ebbeac3c45ae47308a33 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Mar 2026 16:37:22 +0000 Subject: [PATCH] feat: Add kotlinx.serialization KSerializers mirroring all Jackson custom serializers Add 15 KSerializer implementations in commonMain that replicate the exact behavior of all 21 Jackson serializer/deserializer registrations: - EventKSerializer, TagArrayKSerializer, FilterKSerializer - EventTemplateKSerializer, RumorKSerializer, CountResultKSerializer - MessageKSerializer (8 message types), CommandKSerializer (5 command types) - BunkerRequest/Response/MessageKSerializer (NIP-46) - Nip47Request/Response/NotificationKSerializer (NIP-47) - KotlinSerializationMapper as the main entry point Uses JsonElement-based approach for performance. All serializers produce identical JSON output to Jackson. Includes comprehensive test suite (35+ tests) verifying exact output parity and cross-deserialization between Jackson and kotlinx.serialization. https://claude.ai/code/session_01QBc4Pb7a6m4TfLZSJKVdjw --- .../BunkerMessageKSerializer.kt | 97 +++ .../BunkerRequestKSerializer.kt | 100 +++ .../BunkerResponseKSerializer.kt | 110 +++ .../kotlinSerialization/CommandKSerializer.kt | 116 +++ .../CountResultKSerializer.kt | 72 ++ .../kotlinSerialization/EventKSerializer.kt | 111 +++ .../EventTemplateKSerializer.kt | 87 ++ .../kotlinSerialization/FilterKSerializer.kt | 141 ++++ .../KotlinSerializationMapper.kt | 102 +++ .../kotlinSerialization/MessageKSerializer.kt | 136 ++++ .../Nip47NotificationKSerializer.kt | 94 +++ .../Nip47RequestKSerializer.kt | 241 ++++++ .../Nip47ResponseKSerializer.kt | 263 +++++++ .../kotlinSerialization/RumorKSerializer.kt | 93 +++ .../TagArrayKSerializer.kt | 101 +++ .../KotlinSerializationMapperTest.kt | 740 ++++++++++++++++++ 16 files changed, 2604 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerMessageKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerRequestKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerResponseKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventTemplateKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/FilterKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47NotificationKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47RequestKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47ResponseKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/RumorKSerializer.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/TagArrayKSerializer.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerMessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerMessageKSerializer.kt new file mode 100644 index 000000000..157be1b95 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerMessageKSerializer.kt @@ -0,0 +1,97 @@ +/* + * 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 com.vitorpamplona.quartz.nip46RemoteSigner.BunkerMessage +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +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.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +object BunkerMessageKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("BunkerMessage") + + override fun serialize( + encoder: Encoder, + value: BunkerMessage, + ) { + val jsonEncoder = encoder as JsonEncoder + when (value) { + is BunkerRequest -> BunkerRequestKSerializer.serialize(jsonEncoder, value) + is BunkerResponse -> BunkerResponseKSerializer.serialize(jsonEncoder, value) + else -> throw IllegalArgumentException("Unknown BunkerMessage type") + } + } + + override fun deserialize(decoder: Decoder): BunkerMessage { + val jsonDecoder = decoder as JsonDecoder + val jsonObject = jsonDecoder.decodeJsonElement().jsonObject + val isRequest = jsonObject.containsKey("method") + + return if (isRequest) { + val id = jsonObject["id"]!!.jsonPrimitive.content + val method = jsonObject["method"]!!.jsonPrimitive.content + val params = + jsonObject["params"]?.jsonArray?.map { it.jsonPrimitive.content }?.toTypedArray() + ?: emptyArray() + dispatchBunkerRequest(id, method, params) + } else { + BunkerResponseKSerializer.deserializeFromElement(jsonObject) + } + } + + private fun dispatchBunkerRequest( + id: String, + method: String, + params: Array, + ): BunkerRequest { + return when (method) { + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect.METHOD_NAME -> + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect.parse(id, params) + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey.METHOD_NAME -> + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey.parse(id, params) + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays.METHOD_NAME -> + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays.parse(id, params) + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt.METHOD_NAME -> + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt.parse(id, params) + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt.METHOD_NAME -> + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt.parse(id, params) + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt.METHOD_NAME -> + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt.parse(id, params) + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt.METHOD_NAME -> + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt.parse(id, params) + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing.METHOD_NAME -> + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing.parse(id, params) + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign.METHOD_NAME -> + com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign.parse(id, params) + else -> BunkerRequest(id, method, params) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerRequestKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerRequestKSerializer.kt new file mode 100644 index 000000000..3d748bfd0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerRequestKSerializer.kt @@ -0,0 +1,100 @@ +/* + * 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 com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +object BunkerRequestKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("BunkerRequest") { + element("id") + element("method") + element>("params") + } + + override fun serialize( + encoder: Encoder, + value: BunkerRequest, + ) { + val jsonEncoder = encoder as JsonEncoder + val element = + buildJsonObject { + put("id", value.id) + put("method", value.method) + put( + "params", + buildJsonArray { + for (p in value.params) { + add(JsonPrimitive(p)) + } + }, + ) + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): BunkerRequest { + val jsonDecoder = decoder as JsonDecoder + val jsonObject = jsonDecoder.decodeJsonElement().jsonObject + val id = jsonObject["id"]!!.jsonPrimitive.content + val method = jsonObject["method"]!!.jsonPrimitive.content + val params = + jsonObject["params"]?.jsonArray?.map { it.jsonPrimitive.content }?.toTypedArray() + ?: emptyArray() + + return when (method) { + BunkerRequestConnect.METHOD_NAME -> BunkerRequestConnect.parse(id, params) + BunkerRequestGetPublicKey.METHOD_NAME -> BunkerRequestGetPublicKey.parse(id, params) + BunkerRequestGetRelays.METHOD_NAME -> BunkerRequestGetRelays.parse(id, params) + BunkerRequestNip04Decrypt.METHOD_NAME -> BunkerRequestNip04Decrypt.parse(id, params) + BunkerRequestNip04Encrypt.METHOD_NAME -> BunkerRequestNip04Encrypt.parse(id, params) + BunkerRequestNip44Decrypt.METHOD_NAME -> BunkerRequestNip44Decrypt.parse(id, params) + BunkerRequestNip44Encrypt.METHOD_NAME -> BunkerRequestNip44Encrypt.parse(id, params) + BunkerRequestPing.METHOD_NAME -> BunkerRequestPing.parse(id, params) + BunkerRequestSign.METHOD_NAME -> BunkerRequestSign.parse(id, params) + else -> BunkerRequest(id, method, params) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerResponseKSerializer.kt new file mode 100644 index 000000000..bf4cbbaf6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/BunkerResponseKSerializer.kt @@ -0,0 +1,110 @@ +/* + * 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 com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseGetRelays +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey +import com.vitorpamplona.quartz.utils.Hex +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +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.buildJsonObject +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +object BunkerResponseKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("BunkerResponse") { + element("id") + element("result") + element("error") + } + + override fun serialize( + encoder: Encoder, + value: BunkerResponse, + ) { + val jsonEncoder = encoder as JsonEncoder + val element = + buildJsonObject { + put("id", value.id) + value.result?.let { put("result", it) } + value.error?.let { put("error", it) } + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): BunkerResponse { + val jsonDecoder = decoder as JsonDecoder + return deserializeFromElement(jsonDecoder.decodeJsonElement().jsonObject) + } + + fun deserializeFromElement(jsonObject: JsonObject): BunkerResponse { + val id = jsonObject["id"]!!.jsonPrimitive.content + val result = jsonObject["result"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content } + val error = jsonObject["error"]?.let { if (it is JsonNull) null else it.jsonPrimitive.content } + + if (error != null) { + return BunkerResponseError.parse(id, result, error) + } + + if (result != null) { + when (result) { + BunkerResponseAck.RESULT -> return BunkerResponseAck.parse(id, result, error) + BunkerResponsePong.RESULT -> return BunkerResponsePong.parse(id, result, error) + else -> { + if (result.length == 64 && Hex.isHex(result)) { + return BunkerResponsePublicKey.parse(id, result) + } + + if (result.isNotEmpty() && result[0] == '{') { + try { + return BunkerResponseEvent.parse(id, result) + } catch (_: Exception) { + } + + try { + return BunkerResponseGetRelays.parse(id, result) + } catch (_: Exception) { + } + } + + return BunkerResponse(id, result, error) + } + } + } + + return BunkerResponse(id, result, error) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt new file mode 100644 index 000000000..790a710dd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CommandKSerializer.kt @@ -0,0 +1,116 @@ +/* + * 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 com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.AuthCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CountCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +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.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +object CommandKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Command") + + override fun serialize( + encoder: Encoder, + value: Command, + ) { + val jsonEncoder = encoder as JsonEncoder + val element = + buildJsonArray { + add(JsonPrimitive(value.label())) + when (value) { + is ReqCmd -> { + add(JsonPrimitive(value.subId)) + for (filter in value.filters) { + add(FilterKSerializer.serializeToElement(filter)) + } + } + is EventCmd -> { + add(EventKSerializer.serializeToElement(value.event)) + } + is CloseCmd -> { + add(JsonPrimitive(value.subId)) + } + is AuthCmd -> { + add(EventKSerializer.serializeToElement(value.event)) + } + is CountCmd -> { + add(JsonPrimitive(value.queryId)) + for (filter in value.filters) { + add(FilterKSerializer.serializeToElement(filter)) + } + } + } + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): Command { + val jsonDecoder = decoder as JsonDecoder + val array = jsonDecoder.decodeJsonElement().jsonArray + val type = array[0].jsonPrimitive.content + + return when (type) { + ReqCmd.LABEL -> { + val subId = array[1].jsonPrimitive.content + val filters = + (2 until array.size).map { i -> + FilterKSerializer.deserializeFromElement(array[i].jsonObject) + } + ReqCmd(subId, filters) + } + CountCmd.LABEL -> { + val queryId = array[1].jsonPrimitive.content + val filters = + (2 until array.size).map { i -> + FilterKSerializer.deserializeFromElement(array[i].jsonObject) + } + CountCmd(queryId, filters) + } + EventCmd.LABEL -> { + EventCmd(EventKSerializer.deserializeFromElement(array[1].jsonObject)) + } + CloseCmd.LABEL -> { + CloseCmd(array[1].jsonPrimitive.content) + } + AuthCmd.LABEL -> { + AuthCmd(EventKSerializer.deserializeFromElement(array[1].jsonObject) as RelayAuthEvent) + } + else -> throw IllegalArgumentException("Message $type is not supported") + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt new file mode 100644 index 000000000..ed0ff95ae --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/CountResultKSerializer.kt @@ -0,0 +1,72 @@ +/* + * 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 com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put + +object CountResultKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("CountResult") { + element("count") + element("pubkey") + } + + override fun serialize( + encoder: Encoder, + value: CountResult, + ) { + val jsonEncoder = encoder as JsonEncoder + jsonEncoder.encodeJsonElement(serializeToElement(value)) + } + + fun serializeToElement(value: CountResult): JsonObject = + buildJsonObject { + put("count", value.count) + // Matches Jackson's CountResultSerializer which writes "pubkey" for approximate + put("pubkey", value.approximate) + } + + override fun deserialize(decoder: Decoder): CountResult { + val jsonDecoder = decoder as JsonDecoder + return deserializeFromElement(jsonDecoder.decodeJsonElement().jsonObject) + } + + fun deserializeFromElement(jsonObject: JsonObject): CountResult = + CountResult( + count = jsonObject["count"]!!.jsonPrimitive.int, + approximate = jsonObject["approximate"]?.jsonPrimitive?.boolean ?: false, + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventKSerializer.kt new file mode 100644 index 000000000..57d2d56ca --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventKSerializer.kt @@ -0,0 +1,111 @@ +/* + * 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 com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.Kind +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.utils.EventFactory +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put + +object EventKSerializer : KSerializer { + private val emptyTagArray = emptyArray>() + + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Event") { + element("id") + element("pubkey") + element("created_at") + element("kind") + element("tags", TagArrayKSerializer.descriptor) + element("content") + element("sig") + } + + override fun serialize( + encoder: Encoder, + value: Event, + ) { + val jsonEncoder = encoder as JsonEncoder + jsonEncoder.encodeJsonElement(serializeToElement(value)) + } + + fun serializeToElement(event: Event): JsonObject = + buildJsonObject { + put("id", event.id) + put("pubkey", event.pubKey) + put("created_at", event.createdAt) + put("kind", event.kind) + put("tags", TagArrayKSerializer.serializeToElement(event.tags)) + put("content", event.content) + put("sig", event.sig) + } + + override fun deserialize(decoder: Decoder): Event { + val jsonDecoder = decoder as JsonDecoder + return deserializeFromElement(jsonDecoder.decodeJsonElement().jsonObject) + } + + fun deserializeFromElement(jsonObject: JsonObject): Event { + var id: HexKey = "" + var pubKey: HexKey = "" + var createdAt: Long = 0 + var kind: Kind = 0 + var tags: TagArray = emptyTagArray + var content: String = "" + var sig: HexKey = "" + + for ((key, value) in jsonObject) { + when (key) { + "id" -> id = value.jsonPrimitive.content + "pubkey" -> pubKey = value.jsonPrimitive.content + "created_at" -> createdAt = value.jsonPrimitive.long + "kind" -> kind = value.jsonPrimitive.int + "tags" -> tags = TagArrayKSerializer.deserializeFromElement(value) + "content" -> content = value.jsonPrimitive.content + "sig" -> sig = value.jsonPrimitive.content + } + } + + if (pubKey.isEmpty()) { + throw IllegalArgumentException("Event not found") + } + + return EventFactory.create(id, pubKey, createdAt, kind, tags, content, sig) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventTemplateKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventTemplateKSerializer.kt new file mode 100644 index 000000000..58ca13df3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/EventTemplateKSerializer.kt @@ -0,0 +1,87 @@ +/* + * 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 com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put + +object EventTemplateKSerializer : KSerializer> { + private val emptyTagArray = emptyArray>() + + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("EventTemplate") { + element("created_at") + element("kind") + element("tags", TagArrayKSerializer.descriptor) + element("content") + } + + override fun serialize( + encoder: Encoder, + value: EventTemplate, + ) { + val jsonEncoder = encoder as JsonEncoder + val element = + buildJsonObject { + put("created_at", value.createdAt) + put("kind", value.kind) + put("tags", TagArrayKSerializer.serializeToElement(value.tags)) + put("content", value.content) + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): EventTemplate { + val jsonDecoder = decoder as JsonDecoder + val jsonObject = jsonDecoder.decodeJsonElement().jsonObject + + var createdAt = 0L + var kind = 0 + var tags: TagArray = emptyTagArray + var content = "" + + for ((key, value) in jsonObject) { + when (key) { + "created_at" -> createdAt = value.jsonPrimitive.long + "kind" -> kind = value.jsonPrimitive.int + "tags" -> tags = TagArrayKSerializer.deserializeFromElement(value) + "content" -> content = value.jsonPrimitive.content + } + } + + return EventTemplate(createdAt, kind, tags, content) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/FilterKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/FilterKSerializer.kt new file mode 100644 index 000000000..318af177e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/FilterKSerializer.kt @@ -0,0 +1,141 @@ +/* + * 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 com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +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.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.intOrNull +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.longOrNull +import kotlinx.serialization.json.put + +object FilterKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Filter") + + override fun serialize( + encoder: Encoder, + value: Filter, + ) { + val jsonEncoder = encoder as JsonEncoder + jsonEncoder.encodeJsonElement(serializeToElement(value)) + } + + fun serializeToElement(filter: Filter): JsonObject = + buildJsonObject { + filter.kinds?.let { kinds -> + put( + "kinds", + buildJsonArray { + for (k in kinds) add(JsonPrimitive(k)) + }, + ) + } + filter.ids?.let { ids -> + put( + "ids", + buildJsonArray { + for (id in ids) add(JsonPrimitive(id)) + }, + ) + } + filter.authors?.let { authors -> + put( + "authors", + buildJsonArray { + for (a in authors) add(JsonPrimitive(a)) + }, + ) + } + filter.tags?.let { tags -> + for ((key, values) in tags) { + put( + "#$key", + buildJsonArray { + for (v in values) add(JsonPrimitive(v)) + }, + ) + } + } + filter.tagsAll?.let { tagsAll -> + for ((key, values) in tagsAll) { + put( + "&$key", + buildJsonArray { + for (v in values) add(JsonPrimitive(v)) + }, + ) + } + } + filter.since?.let { put("since", it) } + filter.until?.let { put("until", it) } + filter.limit?.let { put("limit", it) } + filter.search?.let { put("search", it) } + } + + override fun deserialize(decoder: Decoder): Filter { + val jsonDecoder = decoder as JsonDecoder + return deserializeFromElement(jsonDecoder.decodeJsonElement().jsonObject) + } + + fun deserializeFromElement(jsonObject: JsonObject): Filter { + val tags = mutableMapOf>() + val tagsAll = mutableMapOf>() + + for ((key, value) in jsonObject) { + when { + key.startsWith("#") -> { + tags[key.substring(1)] = value.jsonArray.mapNotNull { it.jsonPrimitive.content } + } + key.startsWith("&") -> { + tagsAll[key.substring(1)] = value.jsonArray.mapNotNull { it.jsonPrimitive.content } + } + } + } + + return Filter( + ids = jsonObject["ids"]?.jsonArray?.mapNotNull { it.jsonPrimitive.content }, + authors = jsonObject["authors"]?.jsonArray?.mapNotNull { it.jsonPrimitive.content }, + kinds = jsonObject["kinds"]?.jsonArray?.mapNotNull { it.jsonPrimitive.intOrNull }, + tags = tags.ifEmpty { null }, + tagsAll = tagsAll.ifEmpty { null }, + since = jsonObject["since"]?.jsonPrimitive?.longOrNull, + until = jsonObject["until"]?.jsonPrimitive?.longOrNull, + limit = jsonObject["limit"]?.jsonPrimitive?.intOrNull, + search = jsonObject["search"]?.jsonPrimitive?.content, + ) + } +} 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 new file mode 100644 index 000000000..901bdf099 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapper.kt @@ -0,0 +1,102 @@ +/* + * 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 com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerMessage +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip47WalletConnect.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.Request +import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor +import kotlinx.serialization.json.Json + +class KotlinSerializationMapper { + companion object { + val json = + Json { + ignoreUnknownKeys = true + isLenient = true + encodeDefaults = false + } + + fun fromJson(jsonStr: String): Event = json.decodeFromString(EventKSerializer, jsonStr) + + fun fromJsonToMessage(jsonStr: String): Message = json.decodeFromString(MessageKSerializer, jsonStr) + + fun fromJsonToCommand(jsonStr: String): Command = json.decodeFromString(CommandKSerializer, jsonStr) + + fun fromJsonToTagArray(jsonStr: String): TagArray = json.decodeFromString(TagArrayKSerializer, jsonStr) + + fun fromJsonToRumor(jsonStr: String): Rumor = json.decodeFromString(RumorKSerializer, jsonStr) + + fun fromJsonToEventTemplate(jsonStr: String): EventTemplate = json.decodeFromString(EventTemplateKSerializer, jsonStr) + + fun toJson(event: Event): String = json.encodeToString(EventKSerializer, event) + + fun toJson(tags: TagArray): String = json.encodeToString(TagArrayKSerializer, tags) + + fun toJson(value: OptimizedSerializable): String = + when (value) { + is Event -> json.encodeToString(EventKSerializer, value) + is Filter -> json.encodeToString(FilterKSerializer, value) + is Rumor -> json.encodeToString(RumorKSerializer, value) + is EventTemplate<*> -> { + @Suppress("UNCHECKED_CAST") + json.encodeToString(EventTemplateKSerializer, value as EventTemplate) + } + is Message -> json.encodeToString(MessageKSerializer, value) + is Command -> json.encodeToString(CommandKSerializer, value) + is BunkerRequest -> json.encodeToString(BunkerRequestKSerializer, value) + is BunkerResponse -> json.encodeToString(BunkerResponseKSerializer, value) + is BunkerMessage -> json.encodeToString(BunkerMessageKSerializer, value) + else -> throw IllegalArgumentException("Unsupported type: ${value::class}") + } + + inline fun fromJsonTo(jsonStr: String): T { + val result: Any = + when (T::class) { + Event::class -> fromJson(jsonStr) + Filter::class -> json.decodeFromString(FilterKSerializer, jsonStr) + Rumor::class -> fromJsonToRumor(jsonStr) + EventTemplate::class -> fromJsonToEventTemplate(jsonStr) + Message::class -> fromJsonToMessage(jsonStr) + Command::class -> fromJsonToCommand(jsonStr) + BunkerRequest::class -> json.decodeFromString(BunkerRequestKSerializer, jsonStr) + BunkerResponse::class -> json.decodeFromString(BunkerResponseKSerializer, jsonStr) + BunkerMessage::class -> json.decodeFromString(BunkerMessageKSerializer, jsonStr) + Response::class -> json.decodeFromString(Nip47ResponseKSerializer, jsonStr) + Request::class -> json.decodeFromString(Nip47RequestKSerializer, jsonStr) + Notification::class -> json.decodeFromString(Nip47NotificationKSerializer, jsonStr) + else -> throw IllegalArgumentException("Unsupported type: ${T::class}") + } + @Suppress("UNCHECKED_CAST") + return result as T + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt new file mode 100644 index 000000000..f6e66da70 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/MessageKSerializer.kt @@ -0,0 +1,136 @@ +/* + * 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 com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +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.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +object MessageKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Message") + + override fun serialize( + encoder: Encoder, + value: Message, + ) { + val jsonEncoder = encoder as JsonEncoder + val element = + buildJsonArray { + add(JsonPrimitive(value.label())) + when (value) { + is EventMessage -> { + add(JsonPrimitive(value.subId)) + add(EventKSerializer.serializeToElement(value.event)) + } + is NoticeMessage -> { + add(JsonPrimitive(value.message)) + } + is OkMessage -> { + add(JsonPrimitive(value.eventId)) + // Jackson writes success as a string, not boolean + add(JsonPrimitive(value.success.toString())) + if (value.message.isNotBlank()) { + add(JsonPrimitive(value.message)) + } + } + is AuthMessage -> { + add(JsonPrimitive(value.challenge)) + } + is NotifyMessage -> { + add(JsonPrimitive(value.message)) + } + is ClosedMessage -> { + add(JsonPrimitive(value.subId)) + add(JsonPrimitive(value.message)) + } + is CountMessage -> { + add(CountResultKSerializer.serializeToElement(value.result)) + } + } + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): Message { + val jsonDecoder = decoder as JsonDecoder + val array = jsonDecoder.decodeJsonElement().jsonArray + val type = array[0].jsonPrimitive.content + + return when (type) { + EventMessage.LABEL -> { + val subId = array[1].jsonPrimitive.content + val event = EventKSerializer.deserializeFromElement(array[2].jsonObject) + EventMessage(subId, event) + } + EoseMessage.LABEL -> { + EoseMessage(array[1].jsonPrimitive.content) + } + NoticeMessage.LABEL -> { + NoticeMessage(array[1].jsonPrimitive.content) + } + OkMessage.LABEL -> { + OkMessage( + eventId = array[1].jsonPrimitive.content, + success = array[2].jsonPrimitive.boolean, + message = if (array.size > 3) array[3].jsonPrimitive.content else "", + ) + } + AuthMessage.LABEL -> { + AuthMessage(array[1].jsonPrimitive.content) + } + NotifyMessage.LABEL -> { + NotifyMessage(array[1].jsonPrimitive.content) + } + ClosedMessage.LABEL -> { + ClosedMessage( + subId = array[1].jsonPrimitive.content, + message = if (array.size > 2) array[2].jsonPrimitive.content else "", + ) + } + CountMessage.LABEL -> { + val queryId = array[1].jsonPrimitive.content + val result = CountResultKSerializer.deserializeFromElement(array[2].jsonObject) + CountMessage(queryId, result) + } + else -> throw IllegalArgumentException("Message $type is not supported") + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47NotificationKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47NotificationKSerializer.kt new file mode 100644 index 000000000..5ac1a412a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47NotificationKSerializer.kt @@ -0,0 +1,94 @@ +/* + * 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 com.vitorpamplona.quartz.nip47WalletConnect.HoldInvoiceAcceptedData +import com.vitorpamplona.quartz.nip47WalletConnect.HoldInvoiceAcceptedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.Notification +import com.vitorpamplona.quartz.nip47WalletConnect.NwcNotificationType +import com.vitorpamplona.quartz.nip47WalletConnect.PaymentReceivedNotification +import com.vitorpamplona.quartz.nip47WalletConnect.PaymentSentNotification +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.JsonDecoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull + +object Nip47NotificationKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Nip47Notification") + + override fun serialize( + encoder: Encoder, + value: Notification, + ) { + throw UnsupportedOperationException("NIP-47 Notification serialization not supported") + } + + 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 } + + return when (notificationType) { + NwcNotificationType.PAYMENT_RECEIVED -> { + PaymentReceivedNotification( + notification = + Nip47ResponseKSerializer.parseTransaction( + jsonObject["notification"]?.jsonObject, + ), + ) + } + NwcNotificationType.PAYMENT_SENT -> { + PaymentSentNotification( + notification = + Nip47ResponseKSerializer.parseTransaction( + jsonObject["notification"]?.jsonObject, + ), + ) + } + NwcNotificationType.HOLD_INVOICE_ACCEPTED -> { + val notifObj = jsonObject["notification"]?.jsonObject + 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, + ) + }, + ) + } + else -> throw IllegalArgumentException("Unknown notification type: $notificationType") + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47RequestKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47RequestKSerializer.kt new file mode 100644 index 000000000..4bf4f67c6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47RequestKSerializer.kt @@ -0,0 +1,241 @@ +/* + * 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 com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionMethod +import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionParams +import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.GetBudgetMethod +import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoMethod +import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsMethod +import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsParams +import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendMethod +import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendParams +import com.vitorpamplona.quartz.nip47WalletConnect.Request +import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceMethod +import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceParams +import com.vitorpamplona.quartz.nip47WalletConnect.SignMessageMethod +import com.vitorpamplona.quartz.nip47WalletConnect.SignMessageParams +import com.vitorpamplona.quartz.nip47WalletConnect.TlvRecord +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.JsonDecoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.booleanOrNull +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 + +object Nip47RequestKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Nip47Request") + + override fun serialize( + encoder: Encoder, + value: Request, + ) { + throw UnsupportedOperationException("NIP-47 Request serialization not supported") + } + + 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 } + + return when (method) { + NwcMethod.PAY_INVOICE -> parsePayInvoice(jsonObject) + NwcMethod.PAY_KEYSEND -> parsePayKeysend(jsonObject) + NwcMethod.MAKE_INVOICE -> parseMakeInvoice(jsonObject) + NwcMethod.LOOKUP_INVOICE -> parseLookupInvoice(jsonObject) + NwcMethod.LIST_TRANSACTIONS -> parseListTransactions(jsonObject) + NwcMethod.GET_BALANCE -> GetBalanceMethod() + NwcMethod.GET_INFO -> GetInfoMethod() + NwcMethod.GET_BUDGET -> GetBudgetMethod() + NwcMethod.SIGN_MESSAGE -> parseSignMessage(jsonObject) + NwcMethod.CREATE_CONNECTION -> parseCreateConnection(jsonObject) + NwcMethod.MAKE_HOLD_INVOICE -> parseMakeHoldInvoice(jsonObject) + NwcMethod.CANCEL_HOLD_INVOICE -> parseCancelHoldInvoice(jsonObject) + NwcMethod.SETTLE_HOLD_INVOICE -> parseSettleHoldInvoice(jsonObject) + else -> throw IllegalArgumentException("Unknown NWC method: $method") + } + } + + private fun parsePayInvoice(json: JsonObject): PayInvoiceMethod { + val params = json["params"]?.jsonObject + return PayInvoiceMethod( + params?.let { + PayInvoiceParams( + invoice = it["invoice"]?.jsonPrimitive?.content, + amount = it["amount"]?.jsonPrimitive?.longOrNull, + ) + }, + ) + } + + private fun parsePayKeysend(json: JsonObject): PayKeysendMethod { + val params = json["params"]?.jsonObject + return PayKeysendMethod( + params?.let { + PayKeysendParams( + amount = it["amount"]?.jsonPrimitive?.longOrNull, + pubkey = it["pubkey"]?.jsonPrimitive?.content, + preimage = it["preimage"]?.jsonPrimitive?.content, + tlv_records = + it["tlv_records"]?.jsonArray?.map { record -> + val obj = record.jsonObject + TlvRecord( + type = obj["type"]?.jsonPrimitive?.longOrNull, + value = obj["value"]?.jsonPrimitive?.content, + ) + }, + ) + }, + ) + } + + private fun parseMakeInvoice(json: JsonObject): MakeInvoiceMethod { + val params = json["params"]?.jsonObject + 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, + ) + }, + ) + } + + private fun parseLookupInvoice(json: JsonObject): LookupInvoiceMethod { + val params = json["params"]?.jsonObject + return LookupInvoiceMethod( + params?.let { + LookupInvoiceParams( + payment_hash = it["payment_hash"]?.jsonPrimitive?.content, + invoice = it["invoice"]?.jsonPrimitive?.content, + ) + }, + ) + } + + private fun parseListTransactions(json: JsonObject): ListTransactionsMethod { + val params = json["params"]?.jsonObject + 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, + ) + }, + ) + } + + private fun parseSignMessage(json: JsonObject): SignMessageMethod { + val params = json["params"]?.jsonObject + return SignMessageMethod( + params?.let { + SignMessageParams( + message = it["message"]?.jsonPrimitive?.content, + ) + }, + ) + } + + private fun parseCreateConnection(json: JsonObject): CreateConnectionMethod { + val params = json["params"]?.jsonObject + 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, + ) + }, + ) + } + + private fun parseMakeHoldInvoice(json: JsonObject): MakeHoldInvoiceMethod { + val params = json["params"]?.jsonObject + 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, + ) + }, + ) + } + + private fun parseCancelHoldInvoice(json: JsonObject): CancelHoldInvoiceMethod { + val params = json["params"]?.jsonObject + return CancelHoldInvoiceMethod( + params?.let { + CancelHoldInvoiceParams( + payment_hash = it["payment_hash"]?.jsonPrimitive?.content, + ) + }, + ) + } + + private fun parseSettleHoldInvoice(json: JsonObject): SettleHoldInvoiceMethod { + val params = json["params"]?.jsonObject + return SettleHoldInvoiceMethod( + params?.let { + SettleHoldInvoiceParams( + preimage = it["preimage"]?.jsonPrimitive?.content, + ) + }, + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47ResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47ResponseKSerializer.kt new file mode 100644 index 000000000..b01c098a5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/Nip47ResponseKSerializer.kt @@ -0,0 +1,263 @@ +/* + * 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 com.vitorpamplona.quartz.nip47WalletConnect.CancelHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.CreateConnectionSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetBalanceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetBudgetSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.GetInfoSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.ListTransactionsSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.LookupInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.MakeHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.MakeInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.NwcError +import com.vitorpamplona.quartz.nip47WalletConnect.NwcErrorCode +import com.vitorpamplona.quartz.nip47WalletConnect.NwcErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.NwcMethod +import com.vitorpamplona.quartz.nip47WalletConnect.NwcTransaction +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceErrorResponse +import com.vitorpamplona.quartz.nip47WalletConnect.PayInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.PayKeysendSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip47WalletConnect.SettleHoldInvoiceSuccessResponse +import com.vitorpamplona.quartz.nip47WalletConnect.SignMessageSuccessResponse +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.JsonDecoder +import kotlinx.serialization.json.JsonNull +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.longOrNull + +object Nip47ResponseKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Nip47Response") + + override fun serialize( + encoder: Encoder, + value: Response, + ) { + throw UnsupportedOperationException("NIP-47 Response serialization not supported") + } + + 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 hasError = jsonObject["error"]?.let { it !is JsonNull } ?: false + val hasResult = jsonObject["result"]?.let { it !is JsonNull } ?: false + + if (hasError) { + return when (resultType) { + NwcMethod.PAY_INVOICE -> parsePayInvoiceError(jsonObject) + else -> { + val error = jsonObject["error"]?.jsonObject?.let { parseNwcError(it) } + NwcErrorResponse(resultType ?: "", error) + } + } + } + + if (hasResult || resultType != null) { + return when (resultType) { + NwcMethod.PAY_INVOICE -> parsePayInvoiceSuccess(jsonObject) + NwcMethod.PAY_KEYSEND -> parsePayKeysendSuccess(jsonObject) + NwcMethod.MAKE_INVOICE -> MakeInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject)) + NwcMethod.LOOKUP_INVOICE -> LookupInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject)) + NwcMethod.LIST_TRANSACTIONS -> parseListTransactionsSuccess(jsonObject) + NwcMethod.GET_BALANCE -> parseGetBalanceSuccess(jsonObject) + NwcMethod.GET_INFO -> parseGetInfoSuccess(jsonObject) + NwcMethod.GET_BUDGET -> parseGetBudgetSuccess(jsonObject) + NwcMethod.SIGN_MESSAGE -> parseSignMessageSuccess(jsonObject) + NwcMethod.CREATE_CONNECTION -> parseCreateConnectionSuccess(jsonObject) + NwcMethod.MAKE_HOLD_INVOICE -> MakeHoldInvoiceSuccessResponse(parseTransaction(jsonObject["result"]?.jsonObject)) + NwcMethod.CANCEL_HOLD_INVOICE -> CancelHoldInvoiceSuccessResponse() + NwcMethod.SETTLE_HOLD_INVOICE -> SettleHoldInvoiceSuccessResponse() + else -> { + // backward compatibility: guess by result content + val resultObj = jsonObject["result"]?.jsonObject + if (resultObj?.containsKey("preimage") == true) { + return parsePayInvoiceSuccess(jsonObject) + } + throw IllegalArgumentException("Unknown NWC response type: $resultType") + } + } + } + + throw IllegalArgumentException("NWC response has neither result nor error") + } + + private fun parseNwcError(obj: JsonObject): NwcError { + val code = obj["code"]?.jsonPrimitive?.content?.let { codeName -> + try { + NwcErrorCode.valueOf(codeName) + } catch (_: Exception) { + null + } + } + return NwcError(code, obj["message"]?.jsonPrimitive?.content) + } + + 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, + ) + } + + private fun parsePayInvoiceSuccess(json: JsonObject): PayInvoiceSuccessResponse { + val result = json["result"]?.jsonObject + return PayInvoiceSuccessResponse( + result?.let { + PayInvoiceSuccessResponse.PayInvoiceResultParams( + preimage = it["preimage"]?.jsonPrimitive?.content, + fees_paid = it["fees_paid"]?.jsonPrimitive?.longOrNull, + ) + }, + ) + } + + private fun parsePayInvoiceError(json: JsonObject): PayInvoiceErrorResponse { + val error = json["error"]?.jsonObject + return PayInvoiceErrorResponse( + error?.let { + PayInvoiceErrorResponse.PayInvoiceErrorParams( + code = it["code"]?.jsonPrimitive?.content?.let { codeName -> + try { + NwcErrorCode.valueOf(codeName) + } catch (_: Exception) { + null + } + }, + message = it["message"]?.jsonPrimitive?.content, + ) + }, + ) + } + + private fun parsePayKeysendSuccess(json: JsonObject): PayKeysendSuccessResponse { + val result = json["result"]?.jsonObject + return PayKeysendSuccessResponse( + result?.let { + PayKeysendSuccessResponse.PayKeysendResult( + preimage = it["preimage"]?.jsonPrimitive?.content, + fees_paid = it["fees_paid"]?.jsonPrimitive?.longOrNull, + ) + }, + ) + } + + private fun parseListTransactionsSuccess(json: JsonObject): ListTransactionsSuccessResponse { + val result = json["result"]?.jsonObject + return ListTransactionsSuccessResponse( + result?.let { + ListTransactionsSuccessResponse.ListTransactionsResult( + transactions = it["transactions"]?.jsonArray?.map { t -> parseTransaction(t.jsonObject) }, + total_count = it["total_count"]?.jsonPrimitive?.longOrNull, + ) + }, + ) + } + + private fun parseGetBalanceSuccess(json: JsonObject): GetBalanceSuccessResponse { + val result = json["result"]?.jsonObject + return GetBalanceSuccessResponse( + result?.let { + GetBalanceSuccessResponse.GetBalanceResult( + balance = it["balance"]?.jsonPrimitive?.longOrNull, + ) + }, + ) + } + + private fun parseGetInfoSuccess(json: JsonObject): GetInfoSuccessResponse { + val result = json["result"]?.jsonObject + 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 }, + lud16 = it["lud16"]?.jsonPrimitive?.content, + ) + }, + ) + } + + private fun parseGetBudgetSuccess(json: JsonObject): GetBudgetSuccessResponse { + val result = json["result"]?.jsonObject + 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, + ) + }, + ) + } + + private fun parseSignMessageSuccess(json: JsonObject): SignMessageSuccessResponse { + val result = json["result"]?.jsonObject + return SignMessageSuccessResponse( + result?.let { + SignMessageSuccessResponse.SignMessageResult( + message = it["message"]?.jsonPrimitive?.content, + signature = it["signature"]?.jsonPrimitive?.content, + ) + }, + ) + } + + private fun parseCreateConnectionSuccess(json: JsonObject): CreateConnectionSuccessResponse { + val result = json["result"]?.jsonObject + return CreateConnectionSuccessResponse( + result?.let { + CreateConnectionSuccessResponse.CreateConnectionResult( + wallet_pubkey = it["wallet_pubkey"]?.jsonPrimitive?.content, + ) + }, + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/RumorKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/RumorKSerializer.kt new file mode 100644 index 000000000..400eb9017 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/RumorKSerializer.kt @@ -0,0 +1,93 @@ +/* + * 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 com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor +import kotlinx.serialization.KSerializer +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.descriptors.element +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.long +import kotlinx.serialization.json.put + +object RumorKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + buildClassSerialDescriptor("Rumor") { + element("id") + element("pubkey") + element("created_at") + element("kind") + element("tags", TagArrayKSerializer.descriptor) + element("content") + } + + override fun serialize( + encoder: Encoder, + value: Rumor, + ) { + val jsonEncoder = encoder as JsonEncoder + val element = + buildJsonObject { + value.id?.let { put("id", it) } + value.pubKey?.let { put("pubkey", it) } + value.createdAt?.let { put("created_at", it) } + value.kind?.let { put("kind", it) } + value.tags?.let { put("tags", TagArrayKSerializer.serializeToElement(it)) } + value.content?.let { put("content", it) } + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): Rumor { + val jsonDecoder = decoder as JsonDecoder + val jsonObject = jsonDecoder.decodeJsonElement().jsonObject + + var id: HexKey? = null + var pubKey: HexKey? = null + var createdAt: Long? = null + var kind: Int? = null + var tags: TagArray? = null + var content: String? = null + + for ((key, value) in jsonObject) { + when (key) { + "id" -> id = value.jsonPrimitive.content + "pubkey" -> pubKey = value.jsonPrimitive.content + "created_at" -> createdAt = value.jsonPrimitive.long + "kind" -> kind = value.jsonPrimitive.int + "tags" -> tags = TagArrayKSerializer.deserializeFromElement(value) + "content" -> content = value.jsonPrimitive.content + } + } + + return Rumor(id, pubKey, createdAt, kind, tags, content) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/TagArrayKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/TagArrayKSerializer.kt new file mode 100644 index 000000000..3e6562808 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/TagArrayKSerializer.kt @@ -0,0 +1,101 @@ +/* + * 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 com.vitorpamplona.quartz.nip01Core.core.TagArray +import kotlinx.serialization.KSerializer +import kotlinx.serialization.builtins.ListSerializer +import kotlinx.serialization.builtins.serializer +import kotlinx.serialization.descriptors.SerialDescriptor +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.JsonPrimitive +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonPrimitive + +object TagArrayKSerializer : KSerializer { + override val descriptor: SerialDescriptor = + ListSerializer(ListSerializer(String.serializer())).descriptor + + override fun serialize( + encoder: Encoder, + value: TagArray, + ) { + val jsonEncoder = encoder as JsonEncoder + val element = + buildJsonArray { + for (tag in value) { + add( + buildJsonArray { + for (s in tag) { + add(JsonPrimitive(s)) + } + }, + ) + } + } + jsonEncoder.encodeJsonElement(element) + } + + override fun deserialize(decoder: Decoder): TagArray { + val jsonDecoder = decoder as JsonDecoder + return deserializeFromElement(jsonDecoder.decodeJsonElement()) + } + + fun deserializeFromElement(element: JsonElement): TagArray { + val array = element.jsonArray + val outerList = ArrayList>(array.size) + for (inner in array) { + val innerArray = inner.jsonArray + val innerList = ArrayList(innerArray.size.coerceAtLeast(5)) + for (s in innerArray) { + if (s is JsonNull) { + innerList.add("") + } else { + innerList.add(s.jsonPrimitive.content) + } + } + @Suppress("UNCHECKED_CAST") + outerList.add(innerList.toArray(arrayOfNulls(innerList.size)) as Array) + } + @Suppress("UNCHECKED_CAST") + return outerList.toArray(arrayOfNulls>(outerList.size)) as TagArray + } + + fun serializeToElement(value: TagArray): JsonArray = + buildJsonArray { + for (tag in value) { + add( + buildJsonArray { + for (s in tag) { + add(JsonPrimitive(s)) + } + }, + ) + } + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt new file mode 100644 index 000000000..2520242a0 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/kotlinSerialization/KotlinSerializationMapperTest.kt @@ -0,0 +1,740 @@ +/* + * 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 com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.AuthMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.ClosedMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.CountResult +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NoticeMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.NotifyMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.OkMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.CloseCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.EventCmd +import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class KotlinSerializationMapperTest { + val tags = + arrayOf( + arrayOf("title", "Retro Computer Fans"), + arrayOf("d", "xmbspe8rddsq"), + arrayOf("image", "https://blog.johnnovak.net/2022/04/15/achieving-period-correct-graphics-in-personal-computer-emulators-part-1-the-amiga/img/dream-setup.jpg"), + arrayOf("p", "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da"), + arrayOf("p", "9a9a4aa0e43e57873380ab22e8a3df12f3c4cf5bb3a804c6e3fed0069a6e2740"), + arrayOf("p", "4f5dd82517b11088ce00f23d99f06fe8f3e2e45ecf47bc9c2f90f34d5c6f7382"), + arrayOf("p", "ac92102a2ecb873c488e0125354ef5a97075a16198668c360eda050007ed42cd"), + arrayOf("p", "47f54409a4620eb35208a3bc1b53555bf3d0656b246bf0471a93208e20672f6f"), + arrayOf("p", "2624911545afb7a2b440cf10f5c69308afa33aae26fca664d8c94623dc0f1baf"), + arrayOf("p", "6641f26f5c59f7010dbe3e42e4593398e27c087497cb7d20e0e7633a17e48a94"), + arrayOf("description", "Retro computer fans and enthusiasts "), + ) + + val followCard = + FollowListEvent( + id = "eca31634fce7c9068b56fa8db9f387da70bdcceb3986a77ca1a9844f3128eb5f", + pubKey = "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da", + createdAt = 1761736286, + tags = tags, + content = "", + sig = "3aa388edafad151e81cb0228fe04e115dbbcaa851c666bfe3c8740b6cd99575f0fc3ba2d47acda86f7626564a05e9dbc05ef452a7bd0ac00f828dbad0e1bae6c", + ) + + val followCardRumor = + Rumor( + id = followCard.id, + pubKey = followCard.pubKey, + createdAt = followCard.createdAt, + kind = followCard.kind, + tags = followCard.tags, + content = followCard.content, + ) + + val followCardTemplate = + EventTemplate( + createdAt = followCard.createdAt, + kind = followCard.kind, + tags = followCard.tags, + content = followCard.content, + ) + + // ========================================================================= + // TagArray Tests + // ========================================================================= + + @Test + fun serializeTagArray_matchesJackson() { + val jacksonJson = JacksonMapper.toJson(tags) + val kotlinJson = KotlinSerializationMapper.toJson(tags) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeTagArray_matchesJackson() { + val json = JacksonMapper.toJson(tags) + val deserialized = KotlinSerializationMapper.fromJsonToTagArray(json) + tags.forEachIndexed { index, tag -> + assertContentEquals(tag, deserialized[index]) + } + } + + @Test + fun tagArrayRoundTrip() { + val json = KotlinSerializationMapper.toJson(tags) + val deserialized = KotlinSerializationMapper.fromJsonToTagArray(json) + assertEquals(tags.size, deserialized.size) + tags.forEachIndexed { index, tag -> + assertContentEquals(tag, deserialized[index]) + } + } + + @Test + fun tagArrayWithNullValues() { + val json = """[["key",null,"value"]]""" + val deserialized = KotlinSerializationMapper.fromJsonToTagArray(json) + assertEquals(1, deserialized.size) + assertEquals("key", deserialized[0][0]) + assertEquals("", deserialized[0][1]) // null -> "" + assertEquals("value", deserialized[0][2]) + } + + @Test + fun emptyTagArray() { + val json = "[]" + val deserialized = KotlinSerializationMapper.fromJsonToTagArray(json) + assertEquals(0, deserialized.size) + } + + // ========================================================================= + // Event Tests + // ========================================================================= + + @Test + fun serializeEvent_matchesJackson() { + val jacksonJson = JacksonMapper.toJson(followCard) + val kotlinJson = KotlinSerializationMapper.toJson(followCard) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeEvent_matchesJackson() { + val json = JacksonMapper.toJson(followCard) + val deserialized = KotlinSerializationMapper.fromJson(json) + + assertEquals(followCard.id, deserialized.id) + assertEquals(followCard.pubKey, deserialized.pubKey) + assertEquals(followCard.createdAt, deserialized.createdAt) + assertEquals(followCard.kind, deserialized.kind) + assertEquals(followCard.content, deserialized.content) + assertEquals(followCard.sig, deserialized.sig) + tags.forEachIndexed { index, tag -> + assertContentEquals(tag, deserialized.tags[index]) + } + } + + @Test + fun eventRoundTrip() { + val json = KotlinSerializationMapper.toJson(followCard) + val deserialized = KotlinSerializationMapper.fromJson(json) + + assertEquals(followCard.id, deserialized.id) + assertEquals(followCard.kind, deserialized.kind) + assertEquals(followCard.createdAt, deserialized.createdAt) + assertEquals(followCard.pubKey, deserialized.pubKey) + assertEquals(followCard.content, deserialized.content) + assertEquals(followCard.sig, deserialized.sig) + } + + @Test + fun deserializeEventWithUnknownFields() { + val json = + """{"id":"abc123","pubkey":"def456","created_at":12345,"kind":1,"tags":[],"content":"test","sig":"sig123","unknown_field":"ignored"}""" + // Should not throw with unknown fields, should be ignored + val deserialized = KotlinSerializationMapper.fromJson(json) + assertEquals("abc123", deserialized.id) + assertEquals("def456", deserialized.pubKey) + assertEquals("test", deserialized.content) + } + + @Test + fun deserializeEventWithSpecialCharactersInContent() { + val content = "Hello \"world\" \n\ttab\\backslash" + val event = + FollowListEvent( + id = "abc", + pubKey = "def", + createdAt = 1000, + tags = emptyArray(), + content = content, + sig = "sig", + ) + val json = KotlinSerializationMapper.toJson(event) + val deserialized = KotlinSerializationMapper.fromJson(json) + assertEquals(content, deserialized.content) + } + + @Test + fun crossDeserializationEvent() { + // Serialize with Jackson, deserialize with Kotlin Serialization + val jacksonJson = JacksonMapper.toJson(followCard) + val kotlinDeserialized = KotlinSerializationMapper.fromJson(jacksonJson) + + assertEquals(followCard.id, kotlinDeserialized.id) + assertEquals(followCard.pubKey, kotlinDeserialized.pubKey) + assertEquals(followCard.kind, kotlinDeserialized.kind) + + // Serialize with Kotlin Serialization, deserialize with Jackson + val kotlinJson = KotlinSerializationMapper.toJson(followCard) + val jacksonDeserialized = JacksonMapper.fromJson(kotlinJson) + + assertEquals(followCard.id, jacksonDeserialized.id) + assertEquals(followCard.pubKey, jacksonDeserialized.pubKey) + assertEquals(followCard.kind, jacksonDeserialized.kind) + } + + // ========================================================================= + // Rumor Tests + // ========================================================================= + + @Test + fun serializeRumor_matchesJackson() { + val jacksonJson = JacksonMapper.toJson(followCardRumor) + val kotlinJson = KotlinSerializationMapper.toJson(followCardRumor) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeRumor_matchesJackson() { + val json = JacksonMapper.toJson(followCardRumor) + val deserialized = KotlinSerializationMapper.fromJsonToRumor(json) + + assertEquals(followCardRumor.id, deserialized.id) + assertEquals(followCardRumor.pubKey, deserialized.pubKey) + assertEquals(followCardRumor.createdAt, deserialized.createdAt) + assertEquals(followCardRumor.kind, deserialized.kind) + assertEquals(followCardRumor.content, deserialized.content) + } + + @Test + fun rumorRoundTrip() { + val json = KotlinSerializationMapper.toJson(followCardRumor) + val deserialized = KotlinSerializationMapper.fromJsonToRumor(json) + + assertEquals(followCardRumor.id, deserialized.id) + assertEquals(followCardRumor.kind, deserialized.kind) + } + + @Test + fun rumorWithNullFields() { + val rumor = Rumor(null, null, null, null, null, null) + val json = KotlinSerializationMapper.toJson(rumor) + assertEquals("{}", json) + + val deserialized = KotlinSerializationMapper.fromJsonToRumor(json) + assertNull(deserialized.id) + assertNull(deserialized.pubKey) + assertNull(deserialized.createdAt) + assertNull(deserialized.kind) + assertNull(deserialized.tags) + assertNull(deserialized.content) + } + + @Test + fun rumorPartialFields() { + val rumor = Rumor("abc", null, 1000, 1, null, "content") + val json = KotlinSerializationMapper.toJson(rumor) + val deserialized = KotlinSerializationMapper.fromJsonToRumor(json) + + assertEquals("abc", deserialized.id) + assertNull(deserialized.pubKey) + assertEquals(1000L, deserialized.createdAt) + assertEquals(1, deserialized.kind) + assertNull(deserialized.tags) + assertEquals("content", deserialized.content) + } + + // ========================================================================= + // EventTemplate Tests + // ========================================================================= + + @Test + fun serializeTemplate_matchesJackson() { + val jacksonJson = JacksonMapper.toJson(followCardTemplate) + val kotlinJson = KotlinSerializationMapper.toJson(followCardTemplate) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeTemplate_matchesJackson() { + val json = JacksonMapper.toJson(followCardTemplate) + val deserialized = KotlinSerializationMapper.fromJsonToEventTemplate(json) + + assertEquals(followCardTemplate.kind, deserialized.kind) + assertEquals(followCardTemplate.createdAt, deserialized.createdAt) + assertEquals(followCardTemplate.content, deserialized.content) + tags.forEachIndexed { index, tag -> + assertContentEquals(tag, deserialized.tags[index]) + } + } + + @Test + fun templateRoundTrip() { + val json = KotlinSerializationMapper.toJson(followCardTemplate) + val deserialized = KotlinSerializationMapper.fromJsonToEventTemplate(json) + + assertEquals(followCardTemplate.kind, deserialized.kind) + assertEquals(followCardTemplate.createdAt, deserialized.createdAt) + assertEquals(followCardTemplate.content, deserialized.content) + } + + @Test + fun templateWithDifferentFieldOrder() { + // Fields in different order than expected + val json = """{"kind":1,"content":"test","created_at":1234,"tags":[]}""" + val deserialized = KotlinSerializationMapper.fromJsonToEventTemplate(json) + assertEquals(1, deserialized.kind) + assertEquals("test", deserialized.content) + assertEquals(1234L, deserialized.createdAt) + } + + // ========================================================================= + // Filter Tests + // ========================================================================= + + @Test + fun emptyFilter_matchesJackson() { + val filter = Filter() + val jacksonJson = JacksonMapper.toJson(filter) + val kotlinJson = KotlinSerializationMapper.toJson(filter) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun filterWithAllFields_matchesJackson() { + val filter = + Filter( + ids = listOf("abc123" + "0".repeat(58)), + authors = listOf("def456" + "0".repeat(58)), + kinds = listOf(1, 2, 3), + tags = mapOf("p" to listOf("3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da")), + tagsAll = mapOf("p" to listOf("3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da")), + since = 1000L, + until = 2000L, + limit = 50, + search = "hello", + ) + val jacksonJson = JacksonMapper.toJson(filter) + val kotlinJson = KotlinSerializationMapper.toJson(filter) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun filterRoundTrip() { + val expectedTagValue = "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da" + val filter = + Filter( + tags = mapOf("p" to listOf(expectedTagValue)), + tagsAll = mapOf("p" to listOf(expectedTagValue)), + ) + val json = KotlinSerializationMapper.toJson(filter) + val deserialized = KotlinSerializationMapper.fromJsonTo(json) + + assertEquals(true, deserialized.tags?.keys?.contains("p")) + assertEquals(listOf(expectedTagValue), deserialized.tags?.get("p")) + assertEquals(true, deserialized.tagsAll?.keys?.contains("p")) + assertEquals(listOf(expectedTagValue), deserialized.tagsAll?.get("p")) + } + + @Test + fun deserializeEmptyFilter() { + val json = Filter().toJson() + val deserialized = KotlinSerializationMapper.fromJsonTo(json) + assertNull(deserialized.ids) + } + + @Test + fun crossDeserializationFilter() { + val expectedTagValue = "3c39a7b53dec9ac85acf08b267637a9841e6df7b7b0f5e2ac56a8cf107de37da" + val filter = + Filter( + tags = mapOf("p" to listOf(expectedTagValue)), + tagsAll = mapOf("p" to listOf(expectedTagValue)), + ) + + // Jackson serialized -> Kotlin deserialized + val jacksonJson = JacksonMapper.toJson(filter) + val kotlinDeserialized = KotlinSerializationMapper.fromJsonTo(jacksonJson) + assertEquals(listOf(expectedTagValue), kotlinDeserialized.tags?.get("p")) + assertEquals(listOf(expectedTagValue), kotlinDeserialized.tagsAll?.get("p")) + + // Kotlin serialized -> Jackson deserialized + val kotlinJson = KotlinSerializationMapper.toJson(filter) + val jacksonDeserialized = JacksonMapper.fromJsonTo(kotlinJson) + assertEquals(listOf(expectedTagValue), jacksonDeserialized.tags?.get("p")) + assertEquals(listOf(expectedTagValue), jacksonDeserialized.tagsAll?.get("p")) + } + + // ========================================================================= + // Message Tests + // ========================================================================= + + @Test + fun serializeEventMessage_matchesJackson() { + val msg = EventMessage("sub1", followCard) + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeEventMessage() { + val msg = EventMessage("sub1", followCard) + val json = KotlinSerializationMapper.toJson(msg) + val deserialized = KotlinSerializationMapper.fromJsonToMessage(json) + + assertTrue(deserialized is EventMessage) + assertEquals("sub1", deserialized.subId) + assertEquals(followCard.id, deserialized.event.id) + } + + @Test + fun serializeNoticeMessage_matchesJackson() { + val msg = NoticeMessage("something went wrong") + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun serializeOkMessage_matchesJackson() { + val msg = OkMessage("abc123", true, "success") + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeOkMessage() { + val msg = OkMessage("abc123", false, "rate limited") + val json = KotlinSerializationMapper.toJson(msg) + val deserialized = KotlinSerializationMapper.fromJsonToMessage(json) + + assertTrue(deserialized is OkMessage) + assertEquals("abc123", deserialized.eventId) + assertEquals(false, deserialized.success) + assertEquals("rate limited", deserialized.message) + } + + @Test + fun serializeAuthMessage_matchesJackson() { + val msg = AuthMessage("challenge123") + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun serializeNotifyMessage_matchesJackson() { + val msg = NotifyMessage("notification text") + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun serializeClosedMessage_matchesJackson() { + val msg = ClosedMessage("sub1", "subscription closed") + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeEoseMessage() { + val json = """["EOSE","sub123"]""" + val deserialized = KotlinSerializationMapper.fromJsonToMessage(json) + assertTrue(deserialized is EoseMessage) + assertEquals("sub123", (deserialized as EoseMessage).subId) + } + + @Test + fun serializeCountMessage_matchesJackson() { + val msg = CountMessage("q1", CountResult(42, false)) + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinJson = KotlinSerializationMapper.toJson(msg) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun crossDeserializationMessages() { + val messages = + listOf( + NoticeMessage("test"), + AuthMessage("challenge"), + NotifyMessage("notify"), + ClosedMessage("sub1", "reason"), + ) + + for (msg in messages) { + val jacksonJson = JacksonMapper.toJson(msg) + val kotlinDeserialized = KotlinSerializationMapper.fromJsonToMessage(jacksonJson) + assertEquals(msg.label(), kotlinDeserialized.label()) + + val kotlinJson = KotlinSerializationMapper.toJson(msg) + val jacksonDeserialized = JacksonMapper.fromJsonToMessage(kotlinJson) + assertEquals(msg.label(), jacksonDeserialized.label()) + } + } + + // ========================================================================= + // Command Tests + // ========================================================================= + + @Test + fun serializeReqCmd_matchesJackson() { + val filter = + Filter( + kinds = listOf(1), + limit = 10, + ) + val cmd = ReqCmd("sub1", listOf(filter)) + val jacksonJson = JacksonMapper.toJson(cmd) + val kotlinJson = KotlinSerializationMapper.toJson(cmd) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeReqCmd() { + val filter = Filter(kinds = listOf(1), limit = 10) + val cmd = ReqCmd("sub1", listOf(filter)) + val json = KotlinSerializationMapper.toJson(cmd) + val deserialized = KotlinSerializationMapper.fromJsonToCommand(json) + + assertTrue(deserialized is ReqCmd) + assertEquals("sub1", deserialized.subId) + assertEquals(1, deserialized.filters.size) + assertEquals(listOf(1), deserialized.filters[0].kinds) + assertEquals(10, deserialized.filters[0].limit) + } + + @Test + fun serializeEventCmd_matchesJackson() { + val cmd = EventCmd(followCard) + val jacksonJson = JacksonMapper.toJson(cmd) + val kotlinJson = KotlinSerializationMapper.toJson(cmd) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun serializeCloseCmd_matchesJackson() { + val cmd = CloseCmd("sub1") + val jacksonJson = JacksonMapper.toJson(cmd) + val kotlinJson = KotlinSerializationMapper.toJson(cmd) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun crossDeserializationCommands() { + val cmd = CloseCmd("sub1") + + val jacksonJson = JacksonMapper.toJson(cmd) + val kotlinDeserialized = KotlinSerializationMapper.fromJsonToCommand(jacksonJson) + assertTrue(kotlinDeserialized is CloseCmd) + assertEquals("sub1", (kotlinDeserialized as CloseCmd).subId) + + val kotlinJson = KotlinSerializationMapper.toJson(cmd) + val jacksonDeserialized = JacksonMapper.fromJsonToCommand(kotlinJson) + assertTrue(jacksonDeserialized is CloseCmd) + assertEquals("sub1", (jacksonDeserialized as CloseCmd).subId) + } + + // ========================================================================= + // BunkerRequest Tests + // ========================================================================= + + @Test + fun serializeBunkerRequest_matchesJackson() { + val req = BunkerRequest("id1", "connect", arrayOf("pubkey", "secret")) + val jacksonJson = JacksonMapper.toJson(req) + val kotlinJson = KotlinSerializationMapper.toJson(req) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeBunkerRequest() { + val json = """{"id":"id1","method":"ping","params":[]}""" + val deserialized = KotlinSerializationMapper.fromJsonTo(json) + assertEquals("id1", deserialized.id) + assertEquals("ping", deserialized.method) + } + + @Test + fun crossDeserializationBunkerRequest() { + val req = BunkerRequest("id1", "sign_event", arrayOf("eventJson")) + val jacksonJson = JacksonMapper.toJson(req) + val kotlinDeserialized = KotlinSerializationMapper.fromJsonTo(jacksonJson) + assertEquals(req.id, kotlinDeserialized.id) + assertEquals(req.method, kotlinDeserialized.method) + assertContentEquals(req.params, kotlinDeserialized.params) + + val kotlinJson = KotlinSerializationMapper.toJson(req) + val jacksonDeserialized = JacksonMapper.fromJsonTo(kotlinJson) + assertEquals(req.id, jacksonDeserialized.id) + assertEquals(req.method, jacksonDeserialized.method) + assertContentEquals(req.params, jacksonDeserialized.params) + } + + // ========================================================================= + // BunkerResponse Tests + // ========================================================================= + + @Test + fun serializeBunkerResponse_matchesJackson() { + val resp = BunkerResponse("id1", "ok", null) + val jacksonJson = JacksonMapper.toJson(resp) + val kotlinJson = KotlinSerializationMapper.toJson(resp) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun serializeBunkerResponseWithError_matchesJackson() { + val resp = BunkerResponse("id1", null, "something went wrong") + val jacksonJson = JacksonMapper.toJson(resp) + val kotlinJson = KotlinSerializationMapper.toJson(resp) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun deserializeBunkerResponse() { + val json = """{"id":"id1","result":"pong"}""" + val deserialized = KotlinSerializationMapper.fromJsonTo(json) + assertEquals("id1", deserialized.id) + assertNotNull(deserialized.result) + } + + // ========================================================================= + // OptimizedSerializable toJson dispatch Tests + // ========================================================================= + + @Test + fun toJsonDispatchForFilter() { + val filter = Filter(kinds = listOf(1)) + val jacksonJson = JacksonMapper.toJson(filter) + val kotlinJson = KotlinSerializationMapper.toJson(filter) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun toJsonDispatchForRumor() { + val jacksonJson = JacksonMapper.toJson(followCardRumor) + val kotlinJson = KotlinSerializationMapper.toJson(followCardRumor) + assertEquals(jacksonJson, kotlinJson) + } + + @Test + fun toJsonDispatchForEventTemplate() { + val jacksonJson = JacksonMapper.toJson(followCardTemplate) + val kotlinJson = KotlinSerializationMapper.toJson(followCardTemplate) + assertEquals(jacksonJson, kotlinJson) + } + + // ========================================================================= + // Edge Cases + // ========================================================================= + + @Test + fun emptyContentEvent() { + val event = + FollowListEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 0, + tags = emptyArray(), + content = "", + sig = "c".repeat(64), + ) + val json = KotlinSerializationMapper.toJson(event) + val deserialized = KotlinSerializationMapper.fromJson(json) + assertEquals("", deserialized.content) + assertEquals(0, deserialized.tags.size) + } + + @Test + fun largeTagArray() { + val largeTags = Array(100) { i -> arrayOf("p", "key$i") } + val json = KotlinSerializationMapper.toJson(largeTags) + val deserialized = KotlinSerializationMapper.fromJsonToTagArray(json) + assertEquals(100, deserialized.size) + assertEquals("key99", deserialized[99][1]) + } + + @Test + fun eventWithUnicodeContent() { + val content = "Hello \uD83D\uDE00 world \u00E9\u00E8\u00EA" + val event = + FollowListEvent( + id = "a".repeat(64), + pubKey = "b".repeat(64), + createdAt = 1000, + tags = emptyArray(), + content = content, + sig = "c".repeat(64), + ) + val json = KotlinSerializationMapper.toJson(event) + val deserialized = KotlinSerializationMapper.fromJson(json) + assertEquals(content, deserialized.content) + } + + @Test + fun filterWithMultipleTagTypes() { + val filter = + Filter( + tags = + mapOf( + "p" to listOf("pubkey1", "pubkey2"), + "e" to listOf("eventid1"), + "t" to listOf("nostr", "bitcoin"), + ), + ) + val json = KotlinSerializationMapper.toJson(filter) + val deserialized = KotlinSerializationMapper.fromJsonTo(json) + + assertEquals(3, deserialized.tags?.size) + assertEquals(listOf("pubkey1", "pubkey2"), deserialized.tags?.get("p")) + assertEquals(listOf("eventid1"), deserialized.tags?.get("e")) + assertEquals(listOf("nostr", "bitcoin"), deserialized.tags?.get("t")) + } +}