Validate zap receipts against LNURL provider's nostrPubkey (NIP-57 Appendix F)

Receipts were only being checked for a valid event signature — anyone could
sign a kind:9735 and have it counted toward another user's zap totals. NIP-57
Appendix F mandates three additional checks: receipt.pubkey == LNURL
provider's nostrPubkey (MUST), bolt11 invoice amount == zap request "amount"
tag (MUST), and lnurl tag == recipient's lnurl (SHOULD).

- Adds LnZapReceiptValidator + LnurlForm in quartz commonMain (pure logic).
- Adds LnurlEndpointCache (jvmAndroid) and the LnurlEndpointResolver
  interface for async lookup. The cache is primed by outbound zaps (existing
  LightningAddressResolver fetches now extract nostrPubkey) and on demand for
  inbound receipts when no entry is present.
- Adds OkHttpLnurlEndpointResolver in commons, wired into LocalCache via
  AppModules using the existing money-tier OkHttp builder (so Tor settings
  apply).
- LocalCache.consume(LnZapEvent) now: drops receipts that fail MUST checks
  synchronously when the cache is warm, defers credit until async resolution
  finishes on cache miss, and falls back to legacy signature-only behavior
  when no resolver is wired (tests).
- LnZapRequestEvent.create() now accepts amountMillisats + lnurl; both are
  threaded through Account.createZapRequestFor and emitted as tags so future
  receipts can be validated against them.

21 new tests cover validator reasons, lnurl form canonicalization across
lud16/URL/bech32, and cache eviction.
This commit is contained in:
Claude
2026-05-19 16:19:29 +00:00
parent 1afa86cb26
commit 4346427e9f
16 changed files with 951 additions and 23 deletions
@@ -26,6 +26,7 @@ import coil3.disk.DiskCache
import coil3.memory.MemoryCache import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.commons.model.NoteState import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash 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.commons.tor.TorSettings
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.LocalCache
@@ -360,6 +361,12 @@ class AppModules(
client = roleBasedHttpClientBuilder.okHttpClientForMoney(OkHttpBitcoinExplorer.MEMPOOL_API_URL), 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 // Provides a relay pool
@@ -696,6 +696,8 @@ class Account(
zapType: LnZapEvent.ZapType, zapType: LnZapEvent.ZapType,
toUser: User?, toUser: User?,
additionalRelays: Set<NormalizedRelayUrl>? = null, additionalRelays: Set<NormalizedRelayUrl>? = null,
amountMillisats: Long? = null,
lnurl: String? = null,
) = LnZapRequestEvent.create( ) = LnZapRequestEvent.create(
zappedEvent = event, zappedEvent = event,
relays = nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()), relays = nip65RelayList.inboxFlow.value + (additionalRelays ?: emptySet()),
@@ -704,6 +706,8 @@ class Account(
message = message, message = message,
zapType = zapType, zapType = zapType,
toUserPubHex = toUser?.pubkeyHex, toUserPubHex = toUser?.pubkeyHex,
amountMillisats = amountMillisats,
lnurl = lnurl,
) )
suspend fun calculateIfNoteWasZappedByAccount( suspend fun calculateIfNoteWasZappedByAccount(
@@ -743,6 +747,8 @@ class Account(
user: User, user: User,
message: String = "", message: String = "",
zapType: LnZapEvent.ZapType, zapType: LnZapEvent.ZapType,
amountMillisats: Long? = null,
lnurl: String? = null,
): LnZapRequestEvent { ): LnZapRequestEvent {
val zapRequest = val zapRequest =
LnZapRequestEvent.create( LnZapRequestEvent.create(
@@ -751,6 +757,8 @@ class Account(
signer = signer, signer = signer,
message = message, message = message,
zapType = zapType, zapType = zapType,
amountMillisats = amountMillisats,
lnurl = lnurl,
) )
cache.justConsumeMyOwnEvent(zapRequest) cache.justConsumeMyOwnEvent(zapRequest)
@@ -189,6 +189,10 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent 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.accepted.AcceptedBadgeSetEvent
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip58Badges.definition.BadgeDefinitionEvent
@@ -304,6 +308,16 @@ object LocalCache : ILocalCache, ICacheProvider {
@Volatile @Volatile
var onchainBackend: OnchainBackend? = null 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 relayHints = HintIndexer()
val deletionIndex = DeletionIndex() val deletionIndex = DeletionIndex()
@@ -1709,37 +1723,123 @@ object LocalCache : ILocalCache, ICacheProvider {
return false return false
} }
if (wasVerified || justVerify(event)) { if (!(wasVerified || justVerify(event))) return false
val existingZapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) }
if (existingZapRequest == null || existingZapRequest.event == null) { val existingZapRequest = event.zapRequest?.id?.let { getNoteIfExists(it) }
// tries to add it if (existingZapRequest == null || existingZapRequest.event == null) {
event.zapRequest?.let { // tries to add it
checkDeletionAndConsume(it, relay, false) 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) { if (zapRequest == null || zapRequest.event !is LnZapRequestEvent) {
Log.d("ZP") { "Zap Request not found. Unable to process Zap {${event.toJson()}}" } 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 return false
} }
if (result is LnZapReceiptValidator.Result.Invalid) {
val author = getOrCreateUser(event.pubKey) Log.w("ZP", "zap receipt ${event.id} has mismatched lnurl tag (accepting per SHOULD)")
val repliesTo = computeReplyTo(event) }
note.loadEvent(event, author, repliesTo) note.loadEvent(event, author, repliesTo)
repliesTo.forEach { it.addZap(zapRequest, note) } repliesTo.forEach { it.addZap(zapRequest, note) }
attachZapToLiveActivityChannel(event, note, relay) attachZapToLiveActivityChannel(event, note, relay)
refreshNewNoteObservers(note) refreshNewNoteObservers(note)
return true 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( fun consume(
@@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetup
import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress import com.vitorpamplona.quartz.nip57Zaps.splits.ZapSplitSetupLnAddress
import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplitSetup 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.nip89AppHandlers.definition.AppDefinitionEvent
import com.vitorpamplona.quartz.utils.mapNotNullAsync import com.vitorpamplona.quartz.utils.mapNotNullAsync
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -178,7 +179,7 @@ class ZapPaymentHandler(
onProgress(0.02f) onProgress(0.02f)
val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend) val splitZapRequests = signAllZapRequests(note, pollOption, message, zapType, zapsToSend, amountMilliSats)
if (splitZapRequests.isEmpty()) { if (splitZapRequests.isEmpty()) {
onProgress(0.00f) onProgress(0.00f)
@@ -237,8 +238,10 @@ class ZapPaymentHandler(
message: String, message: String,
zapType: LnZapEvent.ZapType, zapType: LnZapEvent.ZapType,
zapsToSend: List<MyZapSplitSetup>, zapsToSend: List<MyZapSplitSetup>,
): List<ZapRequestReady> = totalAmountMilliSats: Long,
mapNotNullAsync(zapsToSend) { next: MyZapSplitSetup -> ): List<ZapRequestReady> {
val totalWeight = zapsToSend.sumOf { it.weight }
return mapNotNullAsync(zapsToSend) { next: MyZapSplitSetup ->
// makes sure the author receives the zap event // makes sure the author receives the zap event
val authorRelayList = note.author?.inboxRelays()?.toSet() ?: emptySet() val authorRelayList = note.author?.inboxRelays()?.toSet() ?: emptySet()
@@ -247,15 +250,31 @@ class ZapPaymentHandler(
val noteEvent = note.event 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 = val zapRequest =
if (zapType != LnZapEvent.ZapType.NONZAP && noteEvent != null) { 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 { } else {
null null
} }
ZapRequestReady(next, zapRequest) ZapRequestReady(next, zapRequest)
} }
}
suspend fun assembleAllInvoices( suspend fun assembleAllInvoices(
requests: List<ZapRequestReady>, requests: List<ZapRequestReady>,
@@ -28,6 +28,8 @@ import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.lightning.Lud06 import com.vitorpamplona.quartz.lightning.Lud06
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent 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 com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
@@ -210,6 +212,8 @@ class LightningAddressResolver {
): String { ): String {
val mapper = jacksonObjectMapper() val mapper = jacksonObjectMapper()
val lnurlpUrl = assembleUrl(lnAddress)
val lnAddressJson = val lnAddressJson =
fetchLightningAddressJson( fetchLightningAddressJson(
lnAddress, lnAddress,
@@ -248,6 +252,17 @@ class LightningAddressResolver {
} }
val allowsNostr = lnurlp.get("allowsNostr")?.asBoolean() ?: false 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 = val invoice =
fetchLightningInvoice( fetchLightningInvoice(
@@ -141,6 +141,7 @@ import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent
import com.vitorpamplona.quartz.nip56Reports.ReportType import com.vitorpamplona.quartz.nip56Reports.ReportType
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip57Zaps.validate.LnurlForm
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
@@ -1980,7 +1981,15 @@ class AccountViewModel(
try { try {
val zapRequest = val zapRequest =
if (defaultZapType() != LnZapEvent.ZapType.NONZAP) { 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 { } else {
null null
} }
@@ -24,6 +24,8 @@ import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.lightning.Lud06 import com.vitorpamplona.quartz.lightning.Lud06
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent 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.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -122,6 +124,15 @@ class LightningAddressResolver(
?: return@withContext Result.Error("No callback URL in LNURL response") ?: return@withContext Result.Error("No callback URL in LNURL response")
val allowsNostr = lnurlp.get("allowsNostr")?.asBoolean() ?: false 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) onProgress(0.5f)
@@ -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/<user>` 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
}
}
}
@@ -95,6 +95,8 @@ class LnZapRequestEvent(
message: String, message: String,
zapType: LnZapEvent.ZapType, zapType: LnZapEvent.ZapType,
toUserPubHex: String?, toUserPubHex: String?,
amountMillisats: Long? = null,
lnurl: String? = null,
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
): LnZapRequestEvent { ): LnZapRequestEvent {
var tags = var tags =
@@ -111,6 +113,12 @@ class LnZapRequestEvent(
if (pollOption != null && pollOption >= 0) { if (pollOption != null && pollOption >= 0) {
tags = tags + listOf(arrayOf(PollOptionTag.TAG_NAME, pollOption.toString())) 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) { return when (zapType) {
LnZapEvent.ZapType.PUBLIC -> { LnZapEvent.ZapType.PUBLIC -> {
@@ -139,6 +147,8 @@ class LnZapRequestEvent(
signer: NostrSigner, signer: NostrSigner,
message: String, message: String,
zapType: LnZapEvent.ZapType, zapType: LnZapEvent.ZapType,
amountMillisats: Long? = null,
lnurl: String? = null,
createdAt: Long = TimeUtils.now(), createdAt: Long = TimeUtils.now(),
): LnZapRequestEvent { ): LnZapRequestEvent {
var tags = var tags =
@@ -146,6 +156,12 @@ class LnZapRequestEvent(
arrayOf("p", userHex), arrayOf("p", userHex),
arrayOf("relays") + relays.map { it.url }, 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) { return when (zapType) {
LnZapEvent.ZapType.PUBLIC -> { LnZapEvent.ZapType.PUBLIC -> {
@@ -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() }
}
@@ -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/<user>` 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,
)
@@ -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/<user>` 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?
}
@@ -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)
}
}
@@ -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<List<String>>()
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<Array<String>>()
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<LnZapReceiptValidator.Result.Invalid>(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<LnZapReceiptValidator.Result.Invalid>(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<LnZapReceiptValidator.Result.Invalid>(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<LnZapReceiptValidator.Result.Invalid>(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<LnZapReceiptValidator.Result.Invalid>(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<LnZapReceiptValidator.Result.Invalid>(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"))
}
}
@@ -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/<user>` 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<String, LnurlEndpointInfo> = 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
}
@@ -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)
}
}