/** * Payment receipts — the one part of a trade that can be **cryptographically * verified** without a server. * * A Lightning wallet returns the **preimage** after it pays an invoice. Anyone * can then check `sha256(preimage) === payment_hash` (decoded from the bolt11). * Published as `["proof","lnpreimage", , ]` on a `31871`, * it turns "trust me, I paid" into a fact. * * ⚠️ What it does NOT prove: that goods were delivered. It proves the invoice * was paid — real sats, real cost. That's exactly what makes it a strong * anti-sybil signal: faking a trade costs money instead of nothing. * * ⚠️ Applies to **money legs only**. A pure hardware barter has no invoice, so * it has no preimage — it stays an *attestation* (see the swap **deposit** idea * in NIP.md). Never promote a barter to "verified". */ import { sha256 } from '@noble/hashes/sha2.js'; import { bytesToHex, hexToBytes } from '@noble/hashes/utils.js'; import { getPaymentHashFromBolt11 } from '@/lib/bolt11'; export interface PaymentReceipt { invoice: string; /** The wallet's preimage, when the rail returns one (NWC / WebLN / melt). */ preimage?: string; /** Payment hash decoded from the invoice (the commitment we verify against). */ paymentHash?: string; } const HEX64 = /^[0-9a-f]{64}$/; /** `sha256(preimage) === payment_hash`? — pure, synchronous, trustless. */ export function verifyPreimage(preimageHex: string, paymentHashHex: string): boolean { try { const expected = paymentHashHex.trim().toLowerCase(); if (!HEX64.test(expected)) return false; const hash = bytesToHex(sha256(hexToBytes(preimageHex.trim().toLowerCase()))); return hash === expected; } catch { return false; } } /** Build a receipt from a paid invoice + the wallet's preimage (if any). */ export function buildReceipt(invoice: string, preimage?: string): PaymentReceipt { return { invoice, preimage: preimage?.trim() || undefined, paymentHash: getPaymentHashFromBolt11(invoice) ?? undefined, }; } /** Verify a `["proof","lnpreimage", preimage, payment_hash]` proof tag. */ export function verifyReceiptProof(preimage?: string, paymentHash?: string): boolean { if (!preimage || !paymentHash) return false; return verifyPreimage(preimage, paymentHash); } /** * A just-made receipt is held device-locally between the PAY step and the * later CONFIRM step (which may be a different component/session), then attached * to the `31871` and cleared. localStorage — never published on its own. */ const STORAGE_KEY = 'skatespots:payment-receipts-v1'; /** A trade can carry two receipts: the payment and (for barter) a deposit. */ export type ReceiptSlot = 'payment' | 'deposit'; function loadAll(): Record { try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Record; } catch { return {}; } } function keyFor(coord: string, slot: ReceiptSlot, owner: string): string { const base = slot === 'deposit' ? `${coord}#deposit` : coord; // Scope to the payer: a receipt must never be attached to a trade by a // different account that happens to view the same listing. return owner ? `${owner}|${base}` : base; } export function saveReceipt(coord: string, receipt: PaymentReceipt, slot: ReceiptSlot = 'payment', owner = ''): void { try { const all = loadAll(); all[keyFor(coord, slot, owner)] = receipt; localStorage.setItem(STORAGE_KEY, JSON.stringify(all)); } catch { /* storage unavailable — the receipt just isn't carried forward */ } } export function getReceipt(coord: string, slot: ReceiptSlot = 'payment', owner = ''): PaymentReceipt | undefined { return loadAll()[keyFor(coord, slot, owner)]; } export function clearReceipt(coord: string, slot: ReceiptSlot = 'payment', owner = ''): void { try { const all = loadAll(); delete all[keyFor(coord, slot, owner)]; localStorage.setItem(STORAGE_KEY, JSON.stringify(all)); } catch { /* ignore */ } }