mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
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:
+44
-20
@@ -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 =
|
||||
|
||||
+14
@@ -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()
|
||||
}
|
||||
+34
@@ -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())
|
||||
}
|
||||
|
||||
+5
-7
@@ -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 =
|
||||
|
||||
+2
-4
@@ -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)) }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+23
-25
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+37
-12
@@ -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())
|
||||
|
||||
+2
@@ -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())
|
||||
|
||||
+37
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user