diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index d05a2935f..f2c5af1dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -26,6 +26,7 @@ import coil3.disk.DiskCache import coil3.memory.MemoryCache import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash +import com.vitorpamplona.amethyst.commons.services.lnurl.OkHttpLnurlEndpointResolver import com.vitorpamplona.amethyst.commons.tor.TorSettings import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -360,6 +361,12 @@ class AppModules( client = roleBasedHttpClientBuilder.okHttpClientForMoney(OkHttpBitcoinExplorer.MEMPOOL_API_URL), ), ) + + // NIP-57 Appendix F: validates incoming zap receipts against the + // recipient's LNURL provider's advertised `nostrPubkey`. Reuses the + // money-tier http client so Tor preferences and proxy settings apply. + cache.lnurlEndpointResolver = + OkHttpLnurlEndpointResolver(roleBasedHttpClientBuilder::okHttpClientForMoney) } // Provides a relay pool diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 94825ee4f..9cd1d901b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -696,6 +696,8 @@ class Account( zapType: LnZapEvent.ZapType, toUser: User?, additionalRelays: Set? = null, + amountMillisats: Long? = null, + lnurl: String? = null, ) = LnZapRequestEvent.create( zappedEvent = event, relays = nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()), @@ -704,6 +706,8 @@ class Account( message = message, zapType = zapType, toUserPubHex = toUser?.pubkeyHex, + amountMillisats = amountMillisats, + lnurl = lnurl, ) suspend fun calculateIfNoteWasZappedByAccount( @@ -743,6 +747,8 @@ class Account( user: User, message: String = "", zapType: LnZapEvent.ZapType, + amountMillisats: Long? = null, + lnurl: String? = null, ): LnZapRequestEvent { val zapRequest = LnZapRequestEvent.create( @@ -751,6 +757,8 @@ class Account( signer = signer, message = message, zapType = zapType, + amountMillisats = amountMillisats, + lnurl = lnurl, ) cache.justConsumeMyOwnEvent(zapRequest) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index c0caa44f6..2d9671bf6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -189,6 +189,10 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.validate.LnZapReceiptValidator +import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointCache +import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointResolver +import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm import com.vitorpamplona.quartz.nip58Badges.accepted.AcceptedBadgeSetEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent @@ -304,6 +308,16 @@ object LocalCache : ILocalCache, ICacheProvider { @Volatile var onchainBackend: OnchainBackend? = null + /** + * Resolver for LNURL provider metadata used by [consume]`(LnZapEvent)` to + * validate NIP-57 Appendix F. `null` skips the receipt-signer check (the + * receipt is still accepted on signature verification alone — matches legacy + * behavior); set this in app init so receipts can be verified against the + * recipient's provider's advertised `nostrPubkey`. + */ + @Volatile + var lnurlEndpointResolver: LnurlEndpointResolver? = null + val relayHints = HintIndexer() val deletionIndex = DeletionIndex() @@ -1709,37 +1723,123 @@ object LocalCache : ILocalCache, ICacheProvider { return false } - if (wasVerified || justVerify(event)) { - val existingZapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) } - if (existingZapRequest == null || existingZapRequest.event == null) { - // tries to add it - event.zapRequest?.let { - checkDeletionAndConsume(it, relay, false) - } + if (!(wasVerified || justVerify(event))) return false + + val existingZapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) } + if (existingZapRequest == null || existingZapRequest.event == null) { + // tries to add it + event.zapRequest?.let { + checkDeletionAndConsume(it, relay, false) } + } - val zapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) } + val zapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) } - if (zapRequest == null || zapRequest.event !is LnZapRequestEvent) { - Log.d("ZP") { "Zap Request not found. Unable to process Zap {${event.toJson()}}" } + if (zapRequest == null || zapRequest.event !is LnZapRequestEvent) { + Log.d("ZP") { "Zap Request not found. Unable to process Zap {${event.toJson()}}" } + return false + } + + // NIP-57 Appendix F validation. Resolve the recipient's lnurl from their + // profile metadata, look up the LNURL provider's `nostrPubkey`, and check + // the receipt against it (signer + invoice amount + lnurl tag). + // + // Synchronous path: cache hit, or no resolver wired (fallback to legacy + // signature-only behavior). Failed MUST-checks drop the receipt entirely. + // + // Async path: cache miss + resolver available. Load the event so feeds + // and live channels see it, but defer the `addZap` credit until the + // resolver returns. On async MUST-fail we never credit; the receipt + // stays in cache as a visible artifact but contributes 0 to zap totals. + val recipientLnurl = recipientLnurl(event) + val recipientLnurlpUrl = recipientLnurl?.let { LnurlForm.toUrl(it) } + val cachedInfo = recipientLnurlpUrl?.let { LnurlEndpointCache.get(it) } + + val author = getOrCreateUser(event.pubKey) + val repliesTo = computeReplyTo(event) + + if (cachedInfo != null) { + val result = + LnZapReceiptValidator.validate( + receipt = event, + expectedNostrPubkey = cachedInfo.nostrPubkey, + expectedLnurl = recipientLnurl, + ) + if (result is LnZapReceiptValidator.Result.Invalid && + result.reason != LnZapReceiptValidator.Result.Reason.MISMATCHED_LNURL + ) { + Log.w("ZP", "dropping zap receipt ${event.id}: ${result.reason} ${result.detail ?: ""}") return false } - - val author = getOrCreateUser(event.pubKey) - val repliesTo = computeReplyTo(event) + if (result is LnZapReceiptValidator.Result.Invalid) { + Log.w("ZP", "zap receipt ${event.id} has mismatched lnurl tag (accepting per SHOULD)") + } note.loadEvent(event, author, repliesTo) - repliesTo.forEach { it.addZap(zapRequest, note) } - attachZapToLiveActivityChannel(event, note, relay) - refreshNewNoteObservers(note) - return true } - return false + val resolver = lnurlEndpointResolver + if (resolver == null || recipientLnurlpUrl == null) { + // No resolver configured, or recipient has no lud16/lud06 on file. + // Legacy behavior: accept the receipt on signature verification alone. + note.loadEvent(event, author, repliesTo) + repliesTo.forEach { it.addZap(zapRequest, note) } + attachZapToLiveActivityChannel(event, note, relay) + refreshNewNoteObservers(note) + return true + } + + // Async validation. Make the event visible immediately, but only credit + // it to repliesTo after the resolver confirms the LNURL provider's pubkey. + note.loadEvent(event, author, repliesTo) + attachZapToLiveActivityChannel(event, note, relay) + refreshNewNoteObservers(note) + + Amethyst.instance.applicationIOScope.launch { + try { + val info = resolver.resolve(recipientLnurlpUrl) + if (info == null) { + Log.w("ZP", "could not fetch lnurlp for ${event.id}; not crediting") + return@launch + } + val result = + LnZapReceiptValidator.validate( + receipt = event, + expectedNostrPubkey = info.nostrPubkey, + expectedLnurl = recipientLnurl, + ) + if (result is LnZapReceiptValidator.Result.Invalid && + result.reason != LnZapReceiptValidator.Result.Reason.MISMATCHED_LNURL + ) { + Log.w("ZP", "dropping zap receipt ${event.id}: ${result.reason} ${result.detail ?: ""}") + return@launch + } + if (result is LnZapReceiptValidator.Result.Invalid) { + Log.w("ZP", "zap receipt ${event.id} has mismatched lnurl tag (accepting per SHOULD)") + } + repliesTo.forEach { it.addZap(zapRequest, note) } + } catch (t: Throwable) { + Log.w("ZP", "validation failed for ${event.id}", t) + } + } + + return true + } + + /** + * Look up the recipient's lnurl from their kind:0 metadata. The recipient is + * the receipt's first `p` tag (which the LNURL provider sets to the original + * zap target). Returns null if we have no metadata, or the user has neither + * lud16 nor lud06. + */ + private fun recipientLnurl(event: LnZapEvent): String? { + val recipientPubkey = event.zappedAuthor().firstOrNull() ?: return null + val user = getUserIfExists(recipientPubkey) ?: return null + return user.lnAddress()?.takeIf { it.isNotBlank() } } fun consume( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt index 951358645..9eb62ea3b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/ZapPaymentHandler.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup +import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.utils.mapNotNullAsync import kotlinx.collections.immutable.ImmutableList @@ -178,7 +179,7 @@ class ZapPaymentHandler( onProgress(0.02f) - val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend) + val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend, amountMilliSats) if (splitZapRequests.isEmpty()) { onProgress(0.00f) @@ -237,8 +238,10 @@ class ZapPaymentHandler( message: String, zapType: LnZapEvent.ZapType, zapsToSend: List, - ): List = - mapNotNullAsync(zapsToSend) { next: MyZapSplitSetup -> + totalAmountMilliSats: Long, + ): List { + val totalWeight = zapsToSend.sumOf { it.weight } + return mapNotNullAsync(zapsToSend) { next: MyZapSplitSetup -> // makes sure the author receives the zap event val authorRelayList = note.author?.inboxRelays()?.toSet() ?: emptySet() @@ -247,15 +250,31 @@ class ZapPaymentHandler( val noteEvent = note.event + // Per NIP-57 §6: the zap request SHOULD carry `amount` (in msats) and + // `lnurl` so the recipient's LNURL provider — and clients reading the + // resulting receipt — can validate them under Appendix F. + val splitAmount = calculateZapValue(totalAmountMilliSats, next.weight, totalWeight) + val splitLnurl = LnurlForm.toUrl(next.lnAddress)?.let(LnurlForm::urlToBech32) + val zapRequest = if (zapType != LnZapEvent.ZapType.NONZAP && noteEvent != null) { - account.createZapRequestFor(noteEvent, pollOption, message, zapType, next.user, userRelayList + authorRelayList) + account.createZapRequestFor( + event = noteEvent, + pollOption = pollOption, + message = message, + zapType = zapType, + toUser = next.user, + additionalRelays = userRelayList + authorRelayList, + amountMillisats = splitAmount, + lnurl = splitLnurl, + ) } else { null } ZapRequestReady(next, zapRequest) } + } suspend fun assembleAllInvoices( requests: List, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt index 9e317e83d..b0ff223ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/lnurl/LightningAddressResolver.kt @@ -28,6 +28,8 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.lightning.Lud06 import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointCache +import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointInfo import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -210,6 +212,8 @@ class LightningAddressResolver { ): String { val mapper = jacksonObjectMapper() + val lnurlpUrl = assembleUrl(lnAddress) + val lnAddressJson = fetchLightningAddressJson( lnAddress, @@ -248,6 +252,17 @@ class LightningAddressResolver { } val allowsNostr = lnurlp.get("allowsNostr")?.asBoolean() ?: false + val nostrPubkey = lnurlp.get("nostrPubkey")?.asText()?.ifBlank { null } + + // Prime the receipt-validation cache so incoming zap receipts can be + // checked against this provider's nostrPubkey (NIP-57 Appendix F) + // without re-fetching the lnurlp endpoint. + if (lnurlpUrl != null) { + LnurlEndpointCache.put( + lnurlpUrl, + LnurlEndpointInfo(nostrPubkey = nostrPubkey, allowsNostr = allowsNostr), + ) + } val invoice = fetchLightningInvoice( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index 92b43cf4d..7f2c0c0a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -141,6 +141,7 @@ import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent @@ -1980,7 +1981,15 @@ class AccountViewModel( try { val zapRequest = if (defaultZapType() != LnZapEvent.ZapType.NONZAP) { - account.createZapRequestFor(user, message, defaultZapType()) + // NIP-57 Appendix F: include amount + lnurl so the receipt can be validated. + val splitLnurl = LnurlForm.toUrl(lnAddress)?.let(LnurlForm::urlToBech32) + account.createZapRequestFor( + user = user, + message = message, + zapType = defaultZapType(), + amountMillisats = milliSats, + lnurl = splitLnurl, + ) } else { null } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/LightningAddressResolver.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/LightningAddressResolver.kt index 9eb824dc7..d6b7768e1 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/LightningAddressResolver.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/LightningAddressResolver.kt @@ -24,6 +24,8 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.lightning.Lud06 import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointCache +import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointInfo import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient @@ -122,6 +124,15 @@ class LightningAddressResolver( ?: return@withContext Result.Error("No callback URL in LNURL response") val allowsNostr = lnurlp.get("allowsNostr")?.asBoolean() ?: false + val nostrPubkey = lnurlp.get("nostrPubkey")?.asText()?.ifBlank { null } + + // Prime the receipt-validation cache so incoming zap receipts can + // be checked against this provider's nostrPubkey (NIP-57 Appendix F) + // without re-fetching the lnurlp endpoint. + LnurlEndpointCache.put( + url, + LnurlEndpointInfo(nostrPubkey = nostrPubkey, allowsNostr = allowsNostr), + ) onProgress(0.5f) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/OkHttpLnurlEndpointResolver.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/OkHttpLnurlEndpointResolver.kt new file mode 100644 index 000000000..0b227af54 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/services/lnurl/OkHttpLnurlEndpointResolver.kt @@ -0,0 +1,76 @@ +/* + * 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.amethyst.commons.services.lnurl + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointCache +import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointInfo +import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlEndpointResolver +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import kotlin.coroutines.cancellation.CancellationException + +/** + * OkHttp-backed [LnurlEndpointResolver]. Used by `LocalCache.consume(LnZapEvent)` + * to look up a recipient's LNURL provider's `nostrPubkey` when validating an + * incoming zap receipt (NIP-57 Appendix F). + * + * Reads from [LnurlEndpointCache] first; on miss, fetches the + * `/.well-known/lnurlp/` endpoint, parses `nostrPubkey` + `allowsNostr`, + * caches the result, and returns it. Returns null on HTTP / parse failure; + * callers should treat that as "validation unavailable" rather than "invalid". + */ +class OkHttpLnurlEndpointResolver( + private val okHttpClient: (String) -> OkHttpClient, +) : LnurlEndpointResolver { + private val mapper = jacksonObjectMapper() + + override suspend fun resolve(lnurlpUrl: String): LnurlEndpointInfo? { + LnurlEndpointCache.get(lnurlpUrl)?.let { return it } + + val info = fetch(lnurlpUrl) ?: return null + LnurlEndpointCache.put(lnurlpUrl, info) + return info + } + + private suspend fun fetch(url: String): LnurlEndpointInfo? = + withContext(Dispatchers.IO) { + try { + val client = okHttpClient(url) + val request = Request.Builder().url(url).build() + client.newCall(request).execute().use { response -> + if (!response.isSuccessful) return@use null + val body = response.body?.string() ?: return@use null + val root = mapper.readTree(body) ?: return@use null + LnurlEndpointInfo( + nostrPubkey = root.get("nostrPubkey")?.asText()?.ifBlank { null }, + allowsNostr = root.get("allowsNostr")?.asBoolean() ?: false, + ) + } + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt index c867fbd9f..21a8aca77 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt @@ -95,6 +95,8 @@ class LnZapRequestEvent( message: String, zapType: LnZapEvent.ZapType, toUserPubHex: String?, + amountMillisats: Long? = null, + lnurl: String? = null, createdAt: Long = TimeUtils.now(), ): LnZapRequestEvent { var tags = @@ -111,6 +113,12 @@ class LnZapRequestEvent( if (pollOption != null && pollOption >= 0) { tags = tags + listOf(arrayOf(PollOptionTag.TAG_NAME, pollOption.toString())) } + if (amountMillisats != null && amountMillisats > 0) { + tags = tags + listOf(arrayOf("amount", amountMillisats.toString())) + } + if (!lnurl.isNullOrBlank()) { + tags = tags + listOf(arrayOf("lnurl", lnurl)) + } return when (zapType) { LnZapEvent.ZapType.PUBLIC -> { @@ -139,6 +147,8 @@ class LnZapRequestEvent( signer: NostrSigner, message: String, zapType: LnZapEvent.ZapType, + amountMillisats: Long? = null, + lnurl: String? = null, createdAt: Long = TimeUtils.now(), ): LnZapRequestEvent { var tags = @@ -146,6 +156,12 @@ class LnZapRequestEvent( arrayOf("p", userHex), arrayOf("relays") + relays.map { it.url }, ) + if (amountMillisats != null && amountMillisats > 0) { + tags += arrayOf(arrayOf("amount", amountMillisats.toString())) + } + if (!lnurl.isNullOrBlank()) { + tags += arrayOf(arrayOf("lnurl", lnurl)) + } return when (zapType) { LnZapEvent.ZapType.PUBLIC -> { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnZapReceiptValidator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnZapReceiptValidator.kt new file mode 100644 index 000000000..c6206814f --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnZapReceiptValidator.kt @@ -0,0 +1,155 @@ +/* + * 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.nip57Zaps.validate + +import com.vitorpamplona.quartz.lightning.LnInvoiceUtil +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.utils.BigDecimal + +/** + * Pure validator for NIP-57 zap receipts (kind 9735), implementing the checks + * spelled out in Appendix F of the spec: + * + * 1. The receipt's `pubkey` MUST equal the recipient's LNURL provider's + * advertised `nostrPubkey`. + * 2. The `invoiceAmount` from the receipt's `bolt11` tag MUST equal the + * `amount` tag of the embedded zap request. + * 3. The `lnurl` tag of the embedded zap request SHOULD equal the + * recipient's `lnurl`. + * + * Signature verification is the caller's responsibility — by the time we get + * here the event should already have passed `Event.verify()`. + * + * Validation is intentionally lenient when the *zap request* lacks the + * `amount` / `lnurl` tags (those tags are optional per NIP-57 §6). The MUST + * check on the receipt's signer pubkey is non-negotiable when an + * `expectedNostrPubkey` is provided. + */ +object LnZapReceiptValidator { + sealed class Result { + data object Valid : Result() + + data class Invalid( + val reason: Reason, + val detail: String? = null, + ) : Result() + + enum class Reason { + /** Receipt is missing its embedded zap request (description tag). */ + MISSING_ZAP_REQUEST, + + /** Receipt has no bolt11 tag, or the invoice is unparseable. */ + MISSING_OR_BAD_BOLT11, + + /** Receipt is signed by a key that is not the recipient's LNURL provider's `nostrPubkey`. */ + MISMATCHED_NOSTR_PUBKEY, + + /** bolt11 invoice amount does not equal the zap request's `amount` tag. */ + MISMATCHED_AMOUNT, + + /** Zap request's `lnurl` tag does not match the recipient's lnurl (SHOULD per spec). */ + MISMATCHED_LNURL, + } + } + + /** + * Validate [receipt] against the expected provider pubkey and recipient lnurl. + * + * @param expectedNostrPubkey the LNURL provider's advertised `nostrPubkey`. + * Pass null to skip the pubkey check (e.g. resolver couldn't fetch). + * @param expectedLnurl the recipient's lnurl in any of the three forms + * ([LnurlForm] handles canonicalization). Pass null to skip the check. + * @param strictAmount when false, a receipt whose embedded zap request has + * no `amount` tag is accepted (matches NIP-57's "optional" wording). + * When true, a missing `amount` tag is treated as a failure. + */ + fun validate( + receipt: LnZapEvent, + expectedNostrPubkey: HexKey?, + expectedLnurl: String?, + strictAmount: Boolean = false, + ): Result { + val zapRequest = + receipt.zapRequest + ?: return Result.Invalid(Result.Reason.MISSING_ZAP_REQUEST) + + // 1. Receipt signer must match LNURL provider's nostrPubkey. + if (expectedNostrPubkey != null && !expectedNostrPubkey.equals(receipt.pubKey, ignoreCase = true)) { + return Result.Invalid( + Result.Reason.MISMATCHED_NOSTR_PUBKEY, + "receipt signer ${receipt.pubKey} != provider $expectedNostrPubkey", + ) + } + + // 2. bolt11 invoice amount must match zap request's "amount" tag. + val invoice = + receipt.lnInvoice() + ?: return Result.Invalid(Result.Reason.MISSING_OR_BAD_BOLT11) + + val invoiceMillisats: BigDecimal = + try { + LnInvoiceUtil.getAmountInSats(invoice).multiply(BigDecimal(1000)) + } catch (_: Exception) { + return Result.Invalid(Result.Reason.MISSING_OR_BAD_BOLT11, "could not parse bolt11") + } + + val requestAmount = zapRequest.amountMillisats() + if (requestAmount != null) { + // Quartz's expect/actual BigDecimal exposes subtract + signum but not compareTo, + // so use signum-after-subtract for value equality. + if (invoiceMillisats.subtract(BigDecimal(requestAmount)).signum() != 0) { + return Result.Invalid( + Result.Reason.MISMATCHED_AMOUNT, + "bolt11 invoice msats != request amount msats ($requestAmount)", + ) + } + } else if (strictAmount) { + return Result.Invalid(Result.Reason.MISMATCHED_AMOUNT, "zap request has no amount tag") + } + + // 3. lnurl tag SHOULD match recipient's lnurl. + if (expectedLnurl != null) { + val requestLnurl = zapRequest.lnurl() + if (requestLnurl != null && !LnurlForm.matches(requestLnurl, expectedLnurl)) { + return Result.Invalid( + Result.Reason.MISMATCHED_LNURL, + "request lnurl=$requestLnurl, expected=$expectedLnurl", + ) + } + } + + return Result.Valid + } + + private fun LnZapRequestEvent.amountMillisats(): Long? = + tags + .firstOrNull { it.size > 1 && it[0] == "amount" } + ?.get(1) + ?.toLongOrNull() + + private fun LnZapRequestEvent.lnurl(): String? = + tags + .firstOrNull { it.size > 1 && it[0] == "lnurl" } + ?.get(1) + ?.takeIf { it.isNotBlank() } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnurlEndpointInfo.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnurlEndpointInfo.kt new file mode 100644 index 000000000..301ba7bb4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnurlEndpointInfo.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.nip57Zaps.validate + +import com.vitorpamplona.quartz.nip01Core.core.HexKey + +/** + * Cached metadata pulled from a recipient's `/.well-known/lnurlp/` endpoint + * that is relevant to NIP-57 zap receipt validation. + * + * `nostrPubkey` is the pubkey the LNURL provider will use to sign zap receipts + * (kind 9735). NIP-57 Appendix F requires the receipt's `pubkey` to equal this + * value. May be null if the provider did not advertise one (i.e. doesn't support + * NIP-57 zaps). + */ +data class LnurlEndpointInfo( + val nostrPubkey: HexKey?, + val allowsNostr: Boolean, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnurlEndpointResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnurlEndpointResolver.kt new file mode 100644 index 000000000..5b1841ada --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnurlEndpointResolver.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.nip57Zaps.validate + +/** + * Resolves an LNURL-pay URL to its [LnurlEndpointInfo]. Implementations should + * consult [LnurlEndpointCache] first and only fall through to a network fetch + * on a miss. Lives in quartz so that pure-logic verifiers (e.g. + * [LnZapReceiptValidator]) can be wired against an abstract interface without + * depending on OkHttp. + */ +fun interface LnurlEndpointResolver { + /** + * @param lnurlpUrl the full `/.well-known/lnurlp/` URL. Caller must + * resolve lud16 or bech32 LNURL to this form before calling. + * @return the parsed endpoint info, or null if it could not be fetched. + */ + suspend fun resolve(lnurlpUrl: String): LnurlEndpointInfo? +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnurlForm.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnurlForm.kt new file mode 100644 index 000000000..098ade2b0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnurlForm.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.nip57Zaps.validate + +import com.vitorpamplona.quartz.lightning.Lud06 +import com.vitorpamplona.quartz.nip19Bech32.toLnUrl + +/** + * Helpers for converting between the three forms an "lnurl" value can take: + * - lud16 (`user@domain`) + * - LNURL-pay URL (`https://domain/.well-known/lnurlp/user`) + * - bech32 LNURL (`lnurl1...`) + * + * NIP-57's `lnurl` tag is most commonly written as the bech32 form, but + * Appendix F only says it SHOULD equal the recipient's lnurl — without + * mandating a form. We canonicalize through the URL form so the comparison + * works regardless of how either side spelled it. + */ +object LnurlForm { + /** + * Resolve any of the three forms to the canonical LNURL-pay URL. Returns + * null if the input doesn't match a known form. + */ + fun toUrl(value: String): String? { + val trimmed = value.trim() + if (trimmed.isEmpty()) return null + + // Already a URL. + if (trimmed.startsWith("http://", ignoreCase = true) || + trimmed.startsWith("https://", ignoreCase = true) + ) { + return trimmed + } + + // Bech32 LNURL. + if (trimmed.startsWith("lnurl", ignoreCase = true)) { + return Lud06().toLnUrlp(trimmed) + } + + // lud16 (`user@domain`). + val parts = trimmed.split("@") + if (parts.size == 2 && parts[0].isNotBlank() && parts[1].isNotBlank()) { + return "https://${parts[1]}/.well-known/lnurlp/${parts[0]}" + } + + return null + } + + /** + * Encode an LNURL-pay URL as a bech32 LNURL string. Used to populate the + * `lnurl` tag on outgoing zap requests in the conventional form. + */ + fun urlToBech32(url: String): String = url.encodeToByteArray().toLnUrl() + + /** + * True if both inputs resolve to the same LNURL-pay URL (case-insensitive + * on scheme + host, case-sensitive on path). Returns false if either side + * can't be resolved to a URL. + */ + fun matches( + a: String, + b: String, + ): Boolean { + val ua = toUrl(a) ?: return false + val ub = toUrl(b) ?: return false + return normalizeUrl(ua) == normalizeUrl(ub) + } + + /** + * Canonicalize an LNURL-pay URL for comparison: trim trailing slashes and + * lowercase the scheme + host while keeping the path case-sensitive (some + * providers are case-sensitive on the username segment). + */ + fun normalizeUrl(url: String): String { + val trimmed = url.trim().trimEnd('/') + val schemeEnd = trimmed.indexOf("://") + if (schemeEnd < 0) return trimmed + val pathStart = trimmed.indexOf('/', schemeEnd + 3) + if (pathStart < 0) return trimmed.lowercase() + return trimmed.substring(0, pathStart).lowercase() + trimmed.substring(pathStart) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnZapReceiptValidatorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnZapReceiptValidatorTest.kt new file mode 100644 index 000000000..a3ce50b8d --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnZapReceiptValidatorTest.kt @@ -0,0 +1,207 @@ +/* + * 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.nip57Zaps.validate + +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class LnZapReceiptValidatorTest { + // Mainnet 100,000 sat invoice (= 100_000_000 msats). From LnInvoiceUtilTest. + private val invoice100kSats = + "lnbc1m1pjt9u0qsp553q90pj5mafzv20w45eqavned9tgwhl4q99n9s5ppcw24nzw3zeqpp5002kd3ktym67du86kj665fgaev7ka8ys7j5yz5fg686lr5e2gfkshp5dkk27nnuax05az3pk2r6ytxtvwn5j4xzsq9ajprhc7crjkmgvr3qxqyjw5qcqpjrzjqtzxvfsuxe4l92pf97tt4rcgpy2xalkmlwexh899wqxf83l8nwv4xzh0gvqq89qqqqqqqqlgqqqqq0gqvs9qxpqysgqx5mz04wd7kqu5zhhel9enr036hjrp4gga0nz084p2asjl36a0zmrk6mhqa249zsgqref2rlvhffm73u7rxgr47gden6rugup4ksvpzsqvds4pz" + + private val providerPubkey = "be1d89794bf92de5dd64c1e60f6a2c70c140abac15c14fda99e75b6db4eaab86" + private val senderPubkey = "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e2c3" + private val recipientPubkey = "3bf0c63fcb93463407af97a5e5ee64fa883d107ef9e558472c4eb9aaaefa459d" + + private val lnurlOfRecipient = "user@example.com" + private val lnurlUrl = "https://example.com/.well-known/lnurlp/user" + + private fun zapRequestJson( + amountMillisats: Long? = 100_000_000L, + lnurl: String? = lnurlOfRecipient, + ): String { + val tags = mutableListOf>() + tags.add(listOf("e", "a".repeat(64))) + tags.add(listOf("p", recipientPubkey)) + tags.add(listOf("relays", "wss://relay.example.com/")) + if (amountMillisats != null) tags.add(listOf("amount", amountMillisats.toString())) + if (lnurl != null) tags.add(listOf("lnurl", lnurl)) + + val tagsJson = + tags.joinToString(",", prefix = "[", postfix = "]") { row -> + row.joinToString(",", prefix = "[", postfix = "]") { "\"$it\"" } + } + + return """{"id":"${"d".repeat(64)}","pubkey":"$senderPubkey","created_at":1700000000,""" + + """"kind":9734,"tags":$tagsJson,"content":"","sig":"${"e".repeat(128)}"}""" + } + + private fun receipt( + signerPubkey: String = providerPubkey, + bolt11: String? = invoice100kSats, + description: String? = zapRequestJson(), + ): LnZapEvent { + val tags = mutableListOf>() + tags.add(arrayOf("p", recipientPubkey)) + if (bolt11 != null) tags.add(arrayOf("bolt11", bolt11)) + if (description != null) tags.add(arrayOf("description", description)) + + return LnZapEvent( + id = "f".repeat(64), + pubKey = signerPubkey, + createdAt = 1700000001, + tags = tags.toTypedArray(), + content = "", + sig = "0".repeat(128), + ) + } + + @Test + fun `valid receipt passes all checks`() { + val result = LnZapReceiptValidator.validate(receipt(), providerPubkey, lnurlOfRecipient) + assertEquals(LnZapReceiptValidator.Result.Valid, result) + } + + @Test + fun `missing zap request fails with MISSING_ZAP_REQUEST`() { + val r = receipt(description = null) + val result = LnZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient) + val invalid = assertIs(result) + assertEquals(LnZapReceiptValidator.Result.Reason.MISSING_ZAP_REQUEST, invalid.reason) + } + + @Test + fun `wrong signer fails with MISMATCHED_NOSTR_PUBKEY`() { + val attackerPubkey = "1".repeat(64) + val result = LnZapReceiptValidator.validate(receipt(signerPubkey = attackerPubkey), providerPubkey, lnurlOfRecipient) + val invalid = assertIs(result) + assertEquals(LnZapReceiptValidator.Result.Reason.MISMATCHED_NOSTR_PUBKEY, invalid.reason) + } + + @Test + fun `null expected pubkey skips the check`() { + val attackerPubkey = "1".repeat(64) + val result = LnZapReceiptValidator.validate(receipt(signerPubkey = attackerPubkey), null, lnurlOfRecipient) + assertEquals(LnZapReceiptValidator.Result.Valid, result) + } + + @Test + fun `missing bolt11 fails with MISSING_OR_BAD_BOLT11`() { + val result = LnZapReceiptValidator.validate(receipt(bolt11 = null), providerPubkey, lnurlOfRecipient) + val invalid = assertIs(result) + assertEquals(LnZapReceiptValidator.Result.Reason.MISSING_OR_BAD_BOLT11, invalid.reason) + } + + @Test + fun `mismatched amount fails with MISMATCHED_AMOUNT`() { + // bolt11 is 100,000,000 msats; request claims 50,000,000. + val r = receipt(description = zapRequestJson(amountMillisats = 50_000_000L)) + val result = LnZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient) + val invalid = assertIs(result) + assertEquals(LnZapReceiptValidator.Result.Reason.MISMATCHED_AMOUNT, invalid.reason) + } + + @Test + fun `missing amount tag is accepted when not strict`() { + val r = receipt(description = zapRequestJson(amountMillisats = null)) + val result = LnZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient, strictAmount = false) + assertEquals(LnZapReceiptValidator.Result.Valid, result) + } + + @Test + fun `missing amount tag fails when strict`() { + val r = receipt(description = zapRequestJson(amountMillisats = null)) + val result = LnZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient, strictAmount = true) + val invalid = assertIs(result) + assertEquals(LnZapReceiptValidator.Result.Reason.MISMATCHED_AMOUNT, invalid.reason) + } + + @Test + fun `mismatched lnurl fails with MISMATCHED_LNURL`() { + val r = receipt(description = zapRequestJson(lnurl = "other@example.org")) + val result = LnZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient) + val invalid = assertIs(result) + assertEquals(LnZapReceiptValidator.Result.Reason.MISMATCHED_LNURL, invalid.reason) + } + + @Test + fun `missing lnurl tag is accepted`() { + val r = receipt(description = zapRequestJson(lnurl = null)) + val result = LnZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient) + assertEquals(LnZapReceiptValidator.Result.Valid, result) + } + + @Test + fun `lnurl tag in URL form matches lud16 expectation`() { + val r = receipt(description = zapRequestJson(lnurl = lnurlUrl)) + val result = LnZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient) + assertEquals(LnZapReceiptValidator.Result.Valid, result) + } + + @Test + fun `lnurl tag in bech32 form matches lud16 expectation`() { + val bech32 = LnurlForm.urlToBech32(lnurlUrl) + val r = receipt(description = zapRequestJson(lnurl = bech32)) + val result = LnZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient) + assertEquals(LnZapReceiptValidator.Result.Valid, result) + } + + @Test + fun `pubkey check is case-insensitive`() { + val r = receipt(signerPubkey = providerPubkey.uppercase()) + val result = LnZapReceiptValidator.validate(r, providerPubkey, lnurlOfRecipient) + assertEquals(LnZapReceiptValidator.Result.Valid, result) + } +} + +class LnurlFormTest { + @Test + fun `lud16 to url`() { + assertEquals("https://example.com/.well-known/lnurlp/vitor", LnurlForm.toUrl("vitor@example.com")) + } + + @Test + fun `https url passthrough`() { + val url = "https://example.com/.well-known/lnurlp/vitor" + assertEquals(url, LnurlForm.toUrl(url)) + } + + @Test + fun `unrecognized input returns null`() { + assertEquals(null, LnurlForm.toUrl("not-a-lnurl")) + assertEquals(null, LnurlForm.toUrl("")) + } + + @Test + fun `matches across forms`() { + assertTrue(LnurlForm.matches("vitor@example.com", "https://example.com/.well-known/lnurlp/vitor")) + assertTrue(LnurlForm.matches("vitor@example.com", "vitor@example.com")) + } + + @Test + fun `does not match different recipients`() { + assertEquals(false, LnurlForm.matches("alice@example.com", "bob@example.com")) + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnurlEndpointCache.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnurlEndpointCache.kt new file mode 100644 index 000000000..e9f2da098 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnurlEndpointCache.kt @@ -0,0 +1,73 @@ +/* + * 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.nip57Zaps.validate + +/** + * Process-wide cache of LNURL-pay endpoint metadata, keyed by the canonical + * `/.well-known/lnurlp/` URL the recipient resolves to. + * + * Hot on the zap path: every incoming kind-9735 receipt has to know the + * recipient's LNURL provider's `nostrPubkey` to validate the signer (NIP-57 + * Appendix F). Without a cache, we'd re-fetch the same lnurlp endpoint for + * every zap from every popular author. Outbound zaps populate the cache as a + * side effect when [com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver] + * fetches the recipient's metadata. + * + * Keys are URLs (not lud16 forms) so callers can convert lud16 / bech32 LNURL + * to a URL once via [normalizeUrl] and look up consistently. + */ +object LnurlEndpointCache { + private const val MAX_ENTRIES = 1000 + + // Insertion-ordered map so we can evict the oldest entry once we hit the cap. + // Synchronized externally — every mutating call holds the monitor. + private val cache: LinkedHashMap = LinkedHashMap() + + @Synchronized + fun get(url: String): LnurlEndpointInfo? = cache[LnurlForm.normalizeUrl(url)] + + @Synchronized + fun put( + url: String, + info: LnurlEndpointInfo, + ) { + val key = LnurlForm.normalizeUrl(url) + // Re-insert so the entry becomes "youngest" in iteration order. + cache.remove(key) + cache[key] = info + if (cache.size > MAX_ENTRIES) { + val oldest = + cache.entries + .iterator() + .next() + .key + cache.remove(oldest) + } + } + + @Synchronized + fun clear() { + cache.clear() + } + + @Synchronized + internal fun size(): Int = cache.size +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnurlEndpointCacheTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnurlEndpointCacheTest.kt new file mode 100644 index 000000000..d23207f0f --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip57Zaps/validate/LnurlEndpointCacheTest.kt @@ -0,0 +1,58 @@ +/* + * 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.nip57Zaps.validate + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LnurlEndpointCacheTest { + @Test + fun `put and get round-trip with normalization`() { + LnurlEndpointCache.clear() + val info = LnurlEndpointInfo(nostrPubkey = "a".repeat(64), allowsNostr = true) + LnurlEndpointCache.put("https://Example.COM/.well-known/lnurlp/Vitor/", info) + assertEquals(info, LnurlEndpointCache.get("https://example.com/.well-known/lnurlp/Vitor")) + } + + @Test + fun `path case is preserved`() { + LnurlEndpointCache.clear() + val info = LnurlEndpointInfo(nostrPubkey = "b".repeat(64), allowsNostr = true) + LnurlEndpointCache.put("https://example.com/.well-known/lnurlp/Vitor", info) + // Different path case is a different cache entry. + assertEquals(null, LnurlEndpointCache.get("https://example.com/.well-known/lnurlp/vitor")) + } + + @Test + fun `eviction at MAX_ENTRIES boundary`() { + LnurlEndpointCache.clear() + for (i in 0 until 1001) { + LnurlEndpointCache.put( + "https://example.com/.well-known/lnurlp/user$i", + LnurlEndpointInfo("c".repeat(64), true), + ) + } + // Cache should not exceed 1000 entries; the first inserted key should be evicted. + assertEquals(null, LnurlEndpointCache.get("https://example.com/.well-known/lnurlp/user0")) + assertTrue(LnurlEndpointCache.get("https://example.com/.well-known/lnurlp/user1000") != null) + } +}