SkateSpots/src/lib/receipts.ts
SkateSpots 4f0416c23d feat(barter): optional good-faith deposit with a verifiable receipt
A pure swap has no invoice, so ListingTradeActions now offers a 'Good-faith
deposit' input for price_type=trade listings. Paying it stores a deposit-slot
receipt (saveReceipt(coord, r, 'deposit')); the buyer's confirm attaches it as
['proof','deposit',preimage,payment_hash] with method=trade, so a barter carries
a verifiable COST without pretending the goods were proven.
2026-10-01 18:39:30 +07:00

103 lines
3.7 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 keyFor(coord: string, slot: ReceiptSlot): string {
return slot === 'deposit' ? `${coord}#deposit` : coord;
}
function loadAll(): Record<string, PaymentReceipt> {
try {
return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Record<string, PaymentReceipt>;
} catch {
return {};
}
}
export function saveReceipt(coord: string, receipt: PaymentReceipt, slot: ReceiptSlot = 'payment'): void {
try {
const all = loadAll();
all[keyFor(coord, slot)] = 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'): PaymentReceipt | undefined {
return loadAll()[keyFor(coord, slot)];
}
export function clearReceipt(coord: string, slot: ReceiptSlot = 'payment'): void {
try {
const all = loadAll();
delete all[keyFor(coord, slot)];
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
} catch {
/* ignore */
}
}