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:
@@ -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>,
|
||||||
|
|||||||
+15
@@ -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(
|
||||||
|
|||||||
+10
-1
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
+11
@@ -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)
|
||||||
|
|
||||||
|
|||||||
+76
@@ -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 -> {
|
||||||
|
|||||||
+155
@@ -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() }
|
||||||
|
}
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.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,
|
||||||
|
)
|
||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.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?
|
||||||
|
}
|
||||||
+100
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+207
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
+73
@@ -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
|
||||||
|
}
|
||||||
+58
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user