Field-testing fixes: timings + read request's own bytes

perf(nwc): read the wallet's info event once per send, not twice
fix(nwc): wait for the wallet's info event before deciding it lacks NWC-06
fix(nwc): send the zap request's own bytes, not a rebuild of it
This commit is contained in:
davotoula
2026-08-30 22:31:29 +02:00
parent 6f71eb0c4c
commit 790458f9a0
10 changed files with 244 additions and 68 deletions
@@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectReque
import com.vitorpamplona.quartz.nip47WalletConnect.cache.NostrWalletConnectResponseCache
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentRequestEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.LnZapPaymentResponseEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcInfoEvent
import com.vitorpamplona.quartz.nip47WalletConnect.events.NwcNotificationEvent
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayInvoiceMethod
@@ -160,27 +161,48 @@ class NwcSignerState(
* is cached as a definitive "no info event" for the whole TTL, which would pin
* the wallet to NIP-04 for days.
*/
private suspend fun prefersNip44(uri: Nip47WalletConnect.Nip47URINorm?): Boolean {
uri ?: return false
val info = withTimeoutOrNull(NIP44_NEGOTIATION_WAIT_MS) { infoCache?.currentOrFetch(uri) }
return info?.encryptionSchemes()?.any { it.equals("nip44_v2", ignoreCase = true) } == true
private fun prefersNip44(info: NwcInfoEvent?): Boolean = info?.encryptionSchemes()?.any { it.equals("nip44_v2", ignoreCase = true) } == true
/**
* The wallet's advertised capabilities, fetched AT MOST ONCE per send.
*
* Every send asks this event two questions — which encryption to use, and
* whether NWC-06 metadata may travel — and each used to fetch it for itself.
* That is free on a warm cache and doubles the stall on a cold one, because
* [NwcInfoCache] deliberately does not cache a FAILED fetch: when the wallet's
* relay is down, both waits run in full. A relay dropping hourly turned a 3s
* worst case into 6s, which is what the wallet operator saw as a stall.
*
* Bounded, and a timeout answers null — the callers treat "don't know" as
* NIP-04 and as no-metadata respectively, both of which are safe.
*/
private suspend fun walletInfo(uri: Nip47WalletConnect.Nip47URINorm?): NwcInfoEvent? {
uri ?: return null
return withTimeoutOrNull(NIP44_NEGOTIATION_WAIT_MS) { infoCache?.currentOrFetch(uri) }
}
/**
* Whether this wallet advertises NWC-06 (metadata conventions) and may therefore
* be sent a populated `metadata`.
*
* NON-BLOCKING, and "don't know" reads as NO. Both matter: this sits on the
* payment path, and the only cost of answering false is a history row without a
* recipient — whereas answering true for a wallet that types `metadata` narrowly
* costs the user a refused payment. [NwcInfoCache.refreshIfStale] means a wallet
* that adds the tag later starts being sent metadata without any user action.
* WAITS ON A COLD CACHE, and that is the whole point. The first version paired
* [NwcInfoCache.refreshIfStale] — which only *starts* a fetch — with [current],
* read immediately after, so a wallet whose info event had not been fetched yet
* answered "no" and the payment went out bare. Interop testing against a live
* wallet caught it: a pairing whose wallet had been advertising `06` for twenty
* minutes still sent no metadata, and nothing anywhere reported an error.
*
* This is the same trap [currentOrFetch] was written for on the NIP-44 path —
* "not fetched yet" is indistinguishable from "not supported" — so it takes the
* same remedy, including the bounded wait so a slow relay cannot stall a
* payment. A timeout still reads as NO: the cost of that is a history row
* without a recipient, whereas a wrong YES to a wallet that types `metadata`
* narrowly costs the user a refused payment.
*
* [currentOrFetch] also kicks a background refresh for an expired entry, so a
* wallet that adds the tag later is picked up without any user action.
*/
private fun supportsMetadata(uri: Nip47WalletConnect.Nip47URINorm?): Boolean {
uri ?: return false
infoCache?.refreshIfStale(uri)
return infoCache?.current(uri)?.supportsExtension(ExtensionsTag.METADATA_CONVENTIONS) == true
}
private fun supportsMetadata(info: NwcInfoEvent?): Boolean = info?.supportsExtension(ExtensionsTag.METADATA_CONVENTIONS) == true
/**
* Strips NWC-06 `metadata` from a request bound for a wallet that never said it
@@ -199,9 +221,9 @@ class NwcSignerState(
* A method with no metadata returns before the info cache is consulted, so this
* costs nothing on the RPCs that can never carry any.
*/
private fun Request.dropMetadataIfUnsupported(walletService: Nip47WalletConnect.Nip47URINorm) {
private fun Request.dropMetadataIfUnsupported(info: NwcInfoEvent?) {
val carrier = metadataCarrier ?: return
if (carrier.metadata == null || supportsMetadata(walletService)) return
if (carrier.metadata == null || supportsMetadata(info)) return
carrier.metadata = null
}
@@ -280,9 +302,10 @@ class NwcSignerState(
val walletService = walletUri ?: throw IllegalArgumentException("No NIP47 setup")
val walletSigner = buildSigner(walletService) ?: signer
request.dropMetadataIfUnsupported(walletService)
val info = walletInfo(walletService)
request.dropMetadataIfUnsupported(info)
val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, walletSigner, useNip44 = prefersNip44(walletService))
val event = LnZapPaymentRequestEvent.createRequest(request, walletService.pubKeyHex, walletSigner, useNip44 = prefersNip44(info))
val filter =
NWCPaymentQueryState(
@@ -326,15 +349,16 @@ class NwcSignerState(
): Pair<LnZapPaymentRequestEvent, NormalizedRelayUrl> {
val walletService = defaultWalletUri.value ?: throw IllegalArgumentException("No NIP47 setup")
val info = walletInfo(walletService)
val request = PayInvoiceMethod.create(bolt11, metadata)
request.dropMetadataIfUnsupported(walletService)
request.dropMetadataIfUnsupported(info)
val event =
LnZapPaymentRequestEvent.createRequest(
request,
walletService.pubKeyHex,
nip47Signer.value,
useNip44 = prefersNip44(walletService),
useNip44 = prefersNip44(info),
)
val filter =
@@ -549,6 +549,20 @@ class WalletViewModel : ViewModel() {
val walletId = _selectedWalletId.value ?: _defaultWalletId.value ?: _wallets.value.firstOrNull()?.id ?: return
val acc = account ?: return
val walletUri = getWalletUri(walletId) ?: return
// Re-read the wallet's advertised capabilities whenever the user opens or
// refreshes this screen, bypassing the info cache's TTL.
//
// A wallet that ADDS an extension is otherwise invisible for the life of the
// cached entry: nothing errors, the feature simply does not appear, and the
// user has no way to learn that. Interop testing found a pairing sending no
// NWC-06 metadata to a wallet that had been advertising `06` for twenty
// minutes. This is the deliberate user-visible remedy — its own coroutine, so
// a slow info fetch never delays the transaction list.
viewModelScope.launch(Dispatchers.IO) {
acc.nip47SignerState.infoCache?.getFresh(walletUri)
}
viewModelScope.launch(Dispatchers.IO) {
_isLoading.value = true
_error.value = null
@@ -0,0 +1,46 @@
/*
* 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.core
/**
* JSON that is already serialized and must reach the wire as-is.
*
* Used where the exact BYTES matter and not merely the value. The case that
* forced it: NIP-57 sets a zap invoice's `description_hash` to the sha256 of the
* raw zap-request JSON the LNURL callback received, so a wallet binding a stored
* zap request to the invoice it labels hashes those same bytes. Handing the
* serializer a decomposed `Map` and hoping it reassembles them identically makes
* that binding depend on key order, escaping and number formatting agreeing by
* coincidence — and it fails silently, as an unlabelled row, when they do not.
*
* Both backends emit the string verbatim: Jackson via `writeRawValue`, kotlinx via
* `JsonUnquotedLiteral`. [json] MUST already be well-formed JSON; nothing
* validates it, and an invalid value corrupts the whole document.
*/
class RawJson(
val json: String,
) {
override fun toString() = json
override fun equals(other: Any?) = other is RawJson && other.json == json
override fun hashCode() = json.hashCode()
}
@@ -20,10 +20,17 @@
*/
package com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization
import com.vitorpamplona.quartz.nip01Core.core.RawJson
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonUnquotedLiteral
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
// Helper function to convert JsonElement to standard Kotlin types recursively
fun JsonElement.toAnyValue(): Any =
@@ -46,3 +53,30 @@ fun JsonElement.toAnyValue(): Any =
}
fun JsonObject.toAnyMap(): Map<String, Any?> = entries.associate { it.key to it.value.toAnyValue() }
/**
* The inverse of [toAnyValue], for the `Map<String, Any?>` blobs NIP-47 carries as
* `metadata`.
*
* `Json.encodeToJsonElement` CANNOT do this — it needs a serializer for the static
* type, and `Any` has none, so it throws `SerializerException: Serializer for class
* 'Any' is not found` at runtime for every populated metadata object. This walks
* the value instead.
*
* [RawJson] becomes a `JsonUnquotedLiteral` so pre-serialized JSON reaches the wire
* byte-for-byte; that is what lets a zap request still hash to the invoice's
* `description_hash` after a round trip through this map.
*/
fun anyToJsonElement(value: Any?): JsonElement =
when (value) {
null -> JsonNull
is RawJson -> JsonUnquotedLiteral(value.json)
is JsonElement -> value
is String -> JsonPrimitive(value)
is Boolean -> JsonPrimitive(value)
is Number -> JsonPrimitive(value)
is Map<*, *> -> buildJsonObject { value.forEach { (k, v) -> put(k.toString(), anyToJsonElement(v)) } }
is Iterable<*> -> buildJsonArray { value.forEach { add(anyToJsonElement(it)) } }
is Array<*> -> buildJsonArray { value.forEach { add(anyToJsonElement(it)) } }
else -> JsonPrimitive(value.toString())
}
@@ -55,7 +55,6 @@ import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonNull
@@ -65,7 +64,6 @@ import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
@@ -148,7 +146,7 @@ object Nip47RequestKSerializer : KSerializer<Request> {
buildJsonObject {
params.invoice?.let { put("invoice", it) }
params.amount?.let { put("amount", it) }
params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) }
params.metadata?.let { put("metadata", anyToJsonElement(it)) }
}
private fun serializePayParams(params: PayParams): JsonObject =
@@ -156,14 +154,14 @@ object Nip47RequestKSerializer : KSerializer<Request> {
params.payment?.let { put("payment", it) }
params.amount?.let { put("amount", it) }
params.payer_note?.let { put("payer_note", it) }
params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) }
params.metadata?.let { put("metadata", anyToJsonElement(it)) }
}
private fun serializeReceiveParams(params: ReceiveParams): JsonObject =
buildJsonObject {
params.amount?.let { put("amount", it) }
params.description?.let { put("description", it) }
params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) }
params.metadata?.let { put("metadata", anyToJsonElement(it)) }
}
private fun serializePayKeysendParams(params: PayKeysendParams): JsonObject =
@@ -194,7 +192,7 @@ object Nip47RequestKSerializer : KSerializer<Request> {
params.description?.let { put("description", it) }
params.description_hash?.let { put("description_hash", it) }
params.expiry?.let { put("expiry", it) }
params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) }
params.metadata?.let { put("metadata", anyToJsonElement(it)) }
}
private fun serializeLookupInvoiceParams(params: LookupInvoiceParams): JsonObject =
@@ -234,7 +232,7 @@ object Nip47RequestKSerializer : KSerializer<Request> {
params.budget_renewal?.let { put("budget_renewal", it) }
params.expires_at?.let { put("expires_at", it) }
params.isolated?.let { put("isolated", it) }
params.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) }
params.metadata?.let { put("metadata", anyToJsonElement(it)) }
}
private fun serializeMakeHoldInvoiceParams(params: MakeHoldInvoiceParams): JsonObject =
@@ -47,7 +47,6 @@ import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.descriptors.buildClassSerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonDecoder
import kotlinx.serialization.json.JsonEncoder
import kotlinx.serialization.json.JsonNull
@@ -56,7 +55,6 @@ import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.encodeToJsonElement
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@@ -226,7 +224,7 @@ object Nip47ResponseKSerializer : KSerializer<Response> {
result.notifications?.let { notifications ->
put("notifications", buildJsonArray { notifications.forEach { add(it) } })
}
result.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) }
result.metadata?.let { put("metadata", anyToJsonElement(it)) }
result.lud16?.let { put("lud16", it) }
}
@@ -373,7 +371,7 @@ object Nip47ResponseKSerializer : KSerializer<Response> {
transaction.expires_at?.let { put("expires_at", it) }
transaction.settled_at?.let { put("settled_at", it) }
transaction.settle_deadline?.let { put("settle_deadline", it) }
transaction.metadata?.let { put("metadata", Json.encodeToJsonElement(it)) }
transaction.metadata?.let { put("metadata", anyToJsonElement(it)) }
}
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.quartz.nip47WalletConnect.rpc
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.RawJson
import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull
class NwcTransactionMetadata(
@@ -126,24 +127,30 @@ class NwcTransactionMetadata(
*/
const val MAX_METADATA_CHARS = 4096
// Slack for the keys and punctuation around the values once serialized —
// The keys and punctuation around the values once serialized —
// `{"recipient_data":{"identifier":""},"comment":"","nostr":}` is ~56 chars.
// Deliberately generous: overshooting drops `nostr` on a payment that would
// just have fit, undershooting sends an object the wallet must throw away.
// Only this wrapper is estimated now; every value's length is exact.
private const val KEY_OVERHEAD = 96
/**
* Assembles NWC-06 `metadata` for an outgoing payment, or null when there is
* nothing worth saying.
*
* `nostr` is built from the event's TYPED fields rather than by re-parsing its
* JSON. A wallet that verifies the request recomputes the event id from these
* values, so `kind` and `created_at` must stay integers — and the obvious
* shortcut breaks exactly that: [com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.toAnyValue]
* resolves untyped numbers with `toDoubleOrNull()` BEFORE `toLongOrNull()`, so
* a JSON round-trip would emit `"kind": 9734.0` on the kotlinx (native) path
* while the JVM/Jackson path stayed correct — invisible on the platform we
* test on, broken on the one we do not.
* `nostr` carries the zap request's OWN serialization verbatim, as [RawJson].
*
* NIP-57 sets a zap invoice's `description_hash` to the sha256 of the raw
* JSON the LNURL callback received in `nostr=`, and that is
* `LnZapRequestEvent.toJson()` — the exact string used here. A wallet can
* therefore bind this stored event to the invoice it labels, which is what
* turns "the client says it paid X" into something the wallet checked.
*
* Rebuilding the object from typed fields would put that binding at the mercy
* of key order, escaping and number formatting matching by coincidence, and
* it fails as a silently unlabelled row rather than as an error. Passing the
* bytes through also sidesteps the number-widening hazard in
* [com.vitorpamplona.quartz.nip47WalletConnect.kotlinSerialization.toAnyValue],
* which resolves untyped numbers with `toDoubleOrNull()` BEFORE
* `toLongOrNull()`: nothing here decomposes the event at all.
*
* When the whole object would breach [MAX_METADATA_CHARS], `nostr` is dropped
* and the much smaller `recipient_data`/`comment` pair survives, so the row
@@ -162,27 +169,18 @@ class NwcTransactionMetadata(
comment?.ifBlank { null }?.let { lean["comment"] = it }
if (zapRequest != null) {
// toJson() is the exact serialized length of the `nostr` sub-object.
// The serialized length of the `nostr` member, exactly — it is the
// string that gets embedded, not a reconstruction of it.
val raw = zapRequest.toJson()
val chars =
recipientIdentifier.orEmpty().length + comment.orEmpty().length +
zapRequest.toJson().length + KEY_OVERHEAD
raw.length + KEY_OVERHEAD
if (chars <= MAX_METADATA_CHARS) {
lean["nostr"] = zapRequestFields(zapRequest)
lean["nostr"] = RawJson(raw)
}
}
return lean.ifEmpty { null }
}
private fun zapRequestFields(event: Event): Map<String, Any?> =
mapOf(
"id" to event.id,
"pubkey" to event.pubKey,
"created_at" to event.createdAt,
"kind" to event.kind,
"tags" to event.tags.map { it.toList() },
"content" to event.content,
"sig" to event.sig,
)
}
}
@@ -73,19 +73,40 @@ class NwcOutgoingMetadataTest {
assertFalse(meta.containsKey("nostr"))
}
/**
* THE INTEROP PROPERTY. NIP-57 sets a zap invoice's `description_hash` to the
* sha256 of the raw JSON the LNURL callback received in `nostr=` — which is
* `LnZapRequestEvent.toJson()` (see LightningAddressResolver). A wallet that
* binds a stored zap request to the invoice it labels hashes the bytes of the
* `nostr` member, so anything short of byte-identity reads as a forged event
* and the row is silently stored unlabelled.
*/
@Test
fun zapRequestTravelsWithTypedFields() {
val meta = assertNotNull(NwcTransactionMetadata.build(zapRequest(), "user@domain.com", "great post"))
fun theNostrMemberIsByteIdenticalToWhatTheLnurlCallbackReceived() {
val event = zapRequest(content = "quoted \" and & <angled> \u00fcn\u00efcode \ud83d\ude00")
val callbackBytes = event.toJson()
@Suppress("UNCHECKED_CAST")
val nostr = meta["nostr"] as Map<String, Any?>
val wire =
OptimizedJsonMapper.toJson(
PayInvoiceMethod.create("lnbc50n1abc", NwcTransactionMetadata.build(event, "user@domain.com", "hi")),
)
// Integers, not floats: a verifying wallet recomputes the event id from these.
assertEquals(9734, nostr["kind"])
assertEquals(1756000000L, nostr["created_at"])
assertEquals(payerHex, nostr["pubkey"])
assertEquals("great post", nostr["content"])
assertEquals(listOf(listOf("p", recipientHex), listOf("relays", "wss://relay.damus.io")), nostr["tags"])
assertTrue(
wire.contains("\"nostr\":" + callbackBytes),
"metadata.nostr must be the callback's own bytes.\n sent: $callbackBytes\n wire: $wire",
)
}
@Test
fun theZapRequestSurvivesAsAReadableObject() {
val event = zapRequest()
val wire = OptimizedJsonMapper.toJson(PayInvoiceMethod.create("lnbc1", NwcTransactionMetadata.build(event, null, null)))
val back = OptimizedJsonMapper.fromJsonTo<Request>(wire) as PayInvoiceMethod
val parsed = assertNotNull(NwcTransactionMetadata.parse(back.params?.metadata))
// Raw on the way out, a normal object on the way back in.
assertEquals(recipientHex, parsed.recipientPubkeyHex())
assertEquals(payerHex, parsed.senderPubkeyHex())
}
@Test
@@ -164,8 +185,12 @@ class NwcOutgoingMetadataTest {
@Test
fun anOutgoingRowResolvesThePayeeFromThePTag() {
val meta = NwcTransactionMetadata.build(zapRequest(content = "for the article"), "user@domain.com", "")
val parsed = assertNotNull(NwcTransactionMetadata.parse(meta))
val wire =
OptimizedJsonMapper.toJson(
PayInvoiceMethod.create("lnbc1", NwcTransactionMetadata.build(zapRequest(content = "for the article"), "user@domain.com", "")),
)
val back = OptimizedJsonMapper.fromJsonTo<Request>(wire) as PayInvoiceMethod
val parsed = assertNotNull(NwcTransactionMetadata.parse(back.params?.metadata))
// The p tag, not the pubkey: on an outgoing zap the pubkey is US.
assertEquals(recipientHex, parsed.recipientPubkeyHex())
@@ -31,6 +31,7 @@ import com.fasterxml.jackson.module.kotlin.jacksonTypeRef
import com.fasterxml.jackson.module.kotlin.readValue
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.OptimizedSerializable
import com.vitorpamplona.quartz.nip01Core.core.RawJson
import com.vitorpamplona.quartz.nip01Core.core.TagArray
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.MessageDeserializer
@@ -84,6 +85,7 @@ class JacksonMapper {
.registerModule(
SimpleModule()
// nip 01
.addSerializer(RawJson::class.java, RawJsonSerializer())
.addSerializer(Event::class.java, EventSerializer())
.addDeserializer(Event::class.java, EventDeserializer())
.addSerializer(Filter::class.java, FilterSerializer())
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip01Core.jackson
import com.fasterxml.jackson.core.JsonGenerator
import com.fasterxml.jackson.databind.SerializerProvider
import com.fasterxml.jackson.databind.ser.std.StdSerializer
import com.vitorpamplona.quartz.nip01Core.core.RawJson
/** Writes [RawJson.json] straight into the output, unquoted and unescaped. */
class RawJsonSerializer : StdSerializer<RawJson>(RawJson::class.java) {
override fun serialize(
value: RawJson,
gen: JsonGenerator,
provider: SerializerProvider,
) {
gen.writeRawValue(value.json)
}
}