diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt index f2e2d50891..8b8c11176f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip47WalletConnect/NwcSignerState.kt @@ -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 { 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 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt index dcd9ee6709..e0453f5386 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/wallet/WalletViewModel.kt @@ -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 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/RawJson.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/RawJson.kt new file mode 100644 index 0000000000..45308316a8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/RawJson.kt @@ -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() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/JsonExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/JsonExt.kt index 472972cb6e..aa06c80365 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/JsonExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/JsonExt.kt @@ -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 = entries.associate { it.key to it.value.toAnyValue() } + +/** + * The inverse of [toAnyValue], for the `Map` 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()) + } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt index fae08b9f97..1b5f71c34a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47RequestKSerializer.kt @@ -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 { 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 { 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 { 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 { 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 = diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt index d938b5ecb6..ae02339bcd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/kotlinSerialization/Nip47ResponseKSerializer.kt @@ -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 { 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 { 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)) } } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt index e9bb723ea3..ab76f141fc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/rpc/NwcTransactionMetadata.kt @@ -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 = - 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, - ) } } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcOutgoingMetadataTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcOutgoingMetadataTest.kt index cf2f7c4edb..bf6a902056 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcOutgoingMetadataTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip47WalletConnect/NwcOutgoingMetadataTest.kt @@ -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 & \u00fcn\u00efcode \ud83d\ude00") + val callbackBytes = event.toJson() - @Suppress("UNCHECKED_CAST") - val nostr = meta["nostr"] as Map + 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(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(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()) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt index 0eccbaedaa..d926ad77a4 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/JacksonMapper.kt @@ -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()) diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/RawJsonSerializer.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/RawJsonSerializer.kt new file mode 100644 index 0000000000..384dbb0480 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/RawJsonSerializer.kt @@ -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::class.java) { + override fun serialize( + value: RawJson, + gen: JsonGenerator, + provider: SerializerProvider, + ) { + gen.writeRawValue(value.json) + } +}