/** * Verifying a payment claim that CAN be checked: a **NIP-57 zap receipt**. * * A `31871` may carry `["proof","zap",""]`. A zap receipt (kind 9735) * is signed by the recipient's **LNURL server** after it received the payment, * so — unlike a hand-typed invoice hash — it is independently checkable by * anyone: we fetch the receipt, verify its signature, that it pays the expected * seller, and what it paid. * * ⚠️ Honest ceiling: the receipt is only as trustworthy as the seller's own * LNURL provider. It proves *that provider* acknowledged a payment to that * seller — not that goods changed hands. Still, it is a real cryptographic * signal, a tier above a free-text claim. */ import { verifyEvent } from 'nostr-tools'; import type { NostrEvent } from '@nostrify/nostrify'; import { getSatoshisFromBolt11 } from '@/lib/bolt11'; export interface ZapReceiptCheck { valid: boolean; sats: number; reason?: string; } /** * Verify a kind-9735 zap receipt pays `expectedRecipient`. * Never throws — returns `{ valid: false, reason }` on any problem. */ export function verifyZapReceipt(receipt: NostrEvent, expectedRecipient: string): ZapReceiptCheck { if (receipt.kind !== 9735) return { valid: false, sats: 0, reason: 'not a zap receipt' }; if (!verifyEvent(receipt)) return { valid: false, sats: 0, reason: 'bad receipt signature' }; const recipient = receipt.tags.find(([n]) => n === 'p')?.[1]; if (recipient !== expectedRecipient) return { valid: false, sats: 0, reason: 'recipient mismatch' }; const bolt11 = receipt.tags.find(([n]) => n === 'bolt11')?.[1]; if (!bolt11) return { valid: false, sats: 0, reason: 'missing invoice' }; let sats = 0; try { sats = getSatoshisFromBolt11(bolt11); } catch { return { valid: false, sats: 0, reason: 'undecodable invoice' }; } if (!Number.isFinite(sats) || sats <= 0) return { valid: false, sats: 0, reason: 'no amount' }; // The embedded `description` is the original zap request (kind 9734); when // present it must also name the same recipient. const description = receipt.tags.find(([n]) => n === 'description')?.[1]; if (description) { try { const request = JSON.parse(description) as NostrEvent; if (request.kind && request.kind !== 9734) { return { valid: false, sats, reason: 'description is not a zap request' }; } const requestRecipient = request.tags?.find(([n]) => n === 'p')?.[1]; if (requestRecipient && requestRecipient !== expectedRecipient) { return { valid: false, sats, reason: 'zap request recipient mismatch' }; } } catch { return { valid: false, sats, reason: 'unparsable description' }; } } return { valid: true, sats }; }