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.
66 lines
2.7 KiB
TypeScript
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 };
|
|
}
|