SkateSpots/src/lib/receipts.ts
SkateSpots 7a5ec82f84 fix(marketplace): escrow-required guard, ask-seller on active, scoped receipts
Correctness batch from MARKETPLACE_FLOW_AUDIT.md:
- canBuy is disabled when the seller requires escrow (item.escrow ===
  'required'), so a direct Buy-with-sats cannot bypass it.
- ask-seller (31872) is available on an active listing, not only once sold,
  so a buyer who paid off-app is no longer at a dead end.
- payment receipts (lib/receipts.ts) are scoped to the payer's pubkey, so a
  stale or foreign receipt cannot be attached to a trade by another account
  viewing the same listing.
2026-10-10 09:08:52 +07:00

106 lines
4 KiB
TypeScript

/**
* 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", <preimage>, <payment_hash>]` 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<string, PaymentReceipt> {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Record<string, PaymentReceipt>;
} 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 */
}
}