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.
103 lines
3.7 KiB
TypeScript
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 */
|
|
}
|
|
}
|