SkateSpots/src/lib/zapReceipt.ts
SkateSpots 50735d1718 feat(reputation): verifiable payment tier via NIP-57 zap receipts
A trade proof of ['proof','zap','<receiptId>'] is now checked by verifyZapReceipt
(kind-9735 signature + p = seller + decodable bolt11 amount + embedded 9734
request) and counted as paymentVerified, shown on TradeReputationCard as
'payment-verified (zap receipt)' - a real tier above the self-reported claim.
Honest ceiling: proves the seller's LNURL provider acknowledged a payment, not
that goods changed hands. NIP.md documents the zap proof variant.
2026-10-01 16:14:55 +07:00

66 lines
2.7 KiB
TypeScript

/**
* Verifying a payment claim that CAN be checked: a **NIP-57 zap receipt**.
*
* A `31871` may carry `["proof","zap","<receiptId>"]`. 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 };
}