feat(receipts): verifiable Lightning preimage receipts (money legs)

- getPaymentHashFromBolt11 (bolt11.ts, dependency-free; matches the BOLT11 spec
  test vector) + verifyPreimage (sha256(preimage) === payment_hash).
- usePayPaymentPointer captures the preimage from NWC/WebLN/melt and returns a
  PaymentReceipt; PayWithSats.onPaid carries it; stored device-locally so the
  later confirm step attaches it; publishTrade writes
  ['proof','lnpreimage',preimage,hash] (and ['proof','deposit',...] for barter).
- useTradeReputation verifies and counts it in paymentVerified.
- Honest scope: proves PAYMENT, not delivery; money legs only - a pure hardware
  swap has no invoice, stays attested; a deposit receipt gives barter real cost.
This commit is contained in:
SkateSpots 2026-10-01 17:25:16 +07:00
parent 4b1c025901
commit e7c6bb1ece
9 changed files with 279 additions and 44 deletions

View file

@ -146,6 +146,16 @@ session with **no other memory of this project**. So:
never pay outside the rails, meet in public). Details stay in the NIP-17 DM —
the app never sees them. `ships` deals now get a safety nudge on **all**
platforms, not only when escrow is available.
- **Meet-at-a-spot + shipment status (2026-10-01):** `HandoffHelper` also
suggests the **3 nearest public skate spots** to the listing (public data
only — **never the viewer's live location**) as one-tap DM meetup templates.
New **addressable kind 34967** (`useShipmentStatus` + `ShippingStatusCard`):
seller marks **shipped**, buyer **received**, either **issue**. `p`-tagged to
the counterparty so the push service notifies (`MARKET_KINDS` in
`push-service`), and `d = ship:<listing-d>:<author>` so a status replaces only
its author's. ⚠️ It's a **claim, not proof** — `received` does **NOT** release
escrow, no address is stored, and parcel photos showing an address are
discouraged.
- **Low-diversity ring warning (2026-10-01):** beyond the single-partner
warning, `TradeReputationCard` flags `verified >= 5 && verifiedCounterparties
<= 2` — "most confirmations come from very few accounts".
@ -168,6 +178,19 @@ session with **no other memory of this project**. So:
exposes `powDifficulty` and `MarketplaceItemCard` shows a **⚡ PoW badge** at
≥16. It raises cost, it doesn't prove humanity; keep targets low (mining runs
on the main thread with periodic yields).
- **Payment receipts — money vs goods (2026-10-01):** `src/lib/receipts.ts` +
`getPaymentHashFromBolt11` (in `lib/bolt11.ts`, **verified against the BOLT11
spec test vector**). `usePayPaymentPointer` now captures the **preimage** from
NWC/WebLN/melt and returns a `PaymentReceipt`; `PayWithSats.onPaid(receipt)`
carries it, held device-locally (`saveReceipt`/`getReceipt`/`clearReceipt`) so
the later CONFIRM step can attach it — `publishTrade({ paymentProof })` writes
`["proof","lnpreimage",preimage,payment_hash]` (and `depositProof` →
`["proof","deposit",…]`). `useTradeReputation` **verifies** it
(`sha256(preimage) === payment_hash`) and counts it in `paymentVerified`.
⚠️ A receipt proves the **PAYMENT, not delivery** — and applies to **money
legs only**: a pure hardware swap has no invoice → no preimage → it stays
*attested*; the `deposit` proof is the lever that gives barter a real cost.
**Never** promote a barter or a plain `lninvoice` claim to verified.
- **Cashu escrow** (marketplace): time-locked **P2PK** (lock to seller + buyer
refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts`
(unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a

15
NIP.md
View file

@ -3236,14 +3236,19 @@ The base NIP has no counterparty field for this use case, so we add:
| `p` | The **other party** in the trade (required by us, marks a trade attestation) |
| `method` | Self-reported payment method: `cash`, `lightning`, `trade`, `other` |
| `amount` | Optional self-reported amount, `["amount","45000","SATS"]` |
| `proof` | Optional payment evidence. `["proof","lninvoice","<payment_hash>"]` — a self-reported hash (a **claim**). `["proof","zap","<receiptId>"]` — a NIP-57 zap receipt the app can **verify** (signature + recipient + amount), which the UI shows as **payment-verified**. |
| `proof` | Optional payment evidence. **Claims:** `["proof","lninvoice","<payment_hash>"]` (a self-typed hash). **Verifiable:** `["proof","lnpreimage","<preimage>","<payment_hash>"]` — anyone checks `sha256(preimage) === payment_hash`; `["proof","zap","<receiptId>"]` — a NIP-57 receipt (signature + recipient + amount). Both show as **payment-verified**. `["proof","deposit","<preimage>","<payment_hash>"]` — a **swap-deposit** receipt, verified the same way (gives a barter a real cost). |
| `rating` | Optional trade review, `1`–`10` (skateboard decks; 1 = bad, 10 = best). It's the reviewer's opinion, shown on both profiles; it is not proof or a score computed by the app. |
`method`/`amount`/`rating` are **self-reported**, never proof of payment or a
verified score — the app cannot observe off-app (cash/trade) settlement. `proof`
is a **reference the author supplies**, not a check the app performs: it lets a
client (or the seller's own BTCPay/Numo webhook, if they run one) confirm the
invoice settled, but until something verifies it, treat it as a soft signal.
verified score — the app cannot observe off-app (cash/trade) settlement.
**Receipts (money vs goods).** Only the **money leg** is verifiable: an
`lnpreimage` proof is checked by hashing, and a `zap` proof by its receipt — both
count as **payment-verified**. A **pure hardware swap has no invoice**, so it has
no preimage and stays an *attestation*; a `deposit` receipt (a small refundable
Lightning deposit) is the way to give a barter a verifiable **cost** without
pretending the goods were proven. **Never promote a barter or a plain
`lninvoice` claim to "verified".**
## Trust model, sybil/collusion limits (honest)

View file

@ -15,6 +15,7 @@ import {
type OfferThread,
} from '@/hooks/useMarketplaceOffers';
import { useSendDirectMessage } from '@/hooks/useDirectMessages';
import { saveReceipt } from '@/lib/receipts';
import { genUserName } from '@/lib/genUserName';
import type { MarketplaceItem } from '@/lib/types';
import { PayWithSats } from '@/components/PayWithSats';
@ -321,9 +322,11 @@ export function ListingOffersPanel({ item, isOwner }: { item: MarketplaceItem; i
amountSats={payFor.latest.amount}
pointer={payFor.latest.payment}
pointerKind={payFor.latest.paymentKind}
onPaid={() =>
setPaidThreads((prev) => new Set(prev).add(`${payFor.listingCoord}:${payFor.buyer}`))
}
onPaid={(receipt) => {
// Carry the verifiable receipt to the confirm step (same coord key).
if (receipt) saveReceipt(payFor.listingCoord, receipt);
setPaidThreads((prev) => new Set(prev).add(`${payFor.listingCoord}:${payFor.buyer}`));
}}
/>
)}
</CardContent>

View file

@ -17,6 +17,8 @@ import { EscrowPanel } from '@/components/EscrowPanel';
import { PayWithSats } from '@/components/PayWithSats';
import { HandoffHelper } from '@/components/HandoffHelper';
import { ShippingStatusCard } from '@/components/ShippingStatusCard';
import { saveReceipt, getReceipt, clearReceipt } from '@/lib/receipts';
import { MARKETPLACE_ITEM_KIND } from '@/lib/types';
import { useUsdToSats } from '@/hooks/useExchangeRate';
import { parseEscrowToken } from '@/lib/cashuEscrow';
import { MessageCircle, Loader2, CheckCircle, Lock, Send, RotateCcw, Zap } from 'lucide-react';
@ -116,9 +118,30 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
const handleConfirmTrade = async () => {
if (!user) return;
// Attach the Lightning receipt captured at Pay time, if any — this is what
// makes the trade payment-VERIFIED (sha256(preimage) === payment_hash).
const coord = `${MARKETPLACE_ITEM_KIND}:${item.pubkey}:${item.identifier}`;
const receipt = getReceipt(coord);
const paymentProof =
receipt?.preimage && receipt.paymentHash
? { preimage: receipt.preimage, paymentHash: receipt.paymentHash }
: undefined;
try {
await publishTrade({ listing: listingRef, role: 'bought', counterparty: item.pubkey });
toast({ title: 'Trade confirmed', description: 'Reputation updated for both of you.' });
await publishTrade({
listing: listingRef,
role: 'bought',
counterparty: item.pubkey,
method: paymentProof ? 'lightning' : undefined,
amountSats: buyAmountSats ?? undefined,
paymentProof,
});
clearReceipt(coord);
toast({
title: 'Trade confirmed',
description: paymentProof
? 'Payment receipt attached — this counts as payment-verified.'
: 'Reputation updated for both of you.',
});
} catch (e) {
toast({
title: 'Could not confirm the trade',
@ -284,12 +307,17 @@ export function ListingTradeActions({ item }: { item: MarketplaceItem }) {
onOpenChange={(open) => { if (!open) setShowPay(false); }}
sellerPubkey={item.pubkey}
amountSats={buyAmountSats ?? undefined}
onPaid={() =>
onPaid={(receipt) => {
if (receipt) {
saveReceipt(`${MARKETPLACE_ITEM_KIND}:${item.pubkey}:${item.identifier}`, receipt);
}
toast({
title: 'Paid ⚡',
description: 'Ask the seller to confirm the trade so it counts for both of you.',
})
}
description: receipt?.preimage
? 'Saved a verifiable receipt — confirm the trade to make it count.'
: 'Ask the seller to confirm the trade so it counts for both of you.',
});
}}
/>
)}

View file

@ -17,6 +17,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { usePayPaymentPointer } from '@/hooks/usePayPaymentPointer';
import { toast } from '@/hooks/useToast';
import type { PaymentKind } from '@/hooks/useMarketplaceOffers';
import type { PaymentReceipt } from '@/lib/receipts';
import { genUserName } from '@/lib/genUserName';
interface PayWithSatsProps {
@ -29,8 +30,8 @@ interface PayWithSatsProps {
/** Pointer attached by the seller on accept, if any. */
pointer?: string;
pointerKind?: PaymentKind;
/** Called once a payment is sent successfully. */
onPaid?: () => void;
/** Called once a payment is sent (receipt carries the verifiable preimage, if any). */
onPaid?: (receipt?: PaymentReceipt) => void;
}
export function PayWithSats({
@ -71,13 +72,15 @@ export function PayWithSats({
if (!effectiveKind) return;
setError(null);
try {
await pay({ pointer: effectivePointer, kind: effectiveKind, amountSats });
const receipt = await pay({ pointer: effectivePointer, kind: effectiveKind, amountSats });
setPaid(true);
toast({
title: 'Payment sent ⚡',
description: 'Let the seller know — they can confirm the trade so it counts for both of you.',
description: receipt.preimage
? 'Saved a verifiable payment receipt — it will count as payment-verified.'
: 'Let the seller know — they can confirm the trade so it counts for both of you.',
});
onPaid?.();
onPaid?.(receipt);
} catch (err) {
const message = err instanceof Error ? err.message : 'Payment failed.';
setError(message);

View file

@ -18,6 +18,7 @@ import { useNWC } from '@/hooks/useNWCContext';
import { useWallet } from '@/hooks/useWallet';
import { useLightningAddress } from '@/hooks/useLightningAddress';
import { useCashu } from '@/hooks/wallet/useCashu';
import { buildReceipt, type PaymentReceipt } from '@/lib/receipts';
import type { PaymentKind } from '@/hooks/useMarketplaceOffers';
export interface PayPointerArgs {
@ -36,7 +37,7 @@ export function usePayPaymentPointer() {
const [isPaying, setIsPaying] = useState(false);
const pay = useCallback(
async ({ pointer, kind, amountSats }: PayPointerArgs): Promise<string> => {
async ({ pointer, kind, amountSats }: PayPointerArgs): Promise<PaymentReceipt> => {
setIsPaying(true);
try {
// 1) Resolve the pointer to a BOLT11 invoice.
@ -58,32 +59,39 @@ export function usePayPaymentPointer() {
);
}
// 2) Pay it with the best available rail.
if (activeNWC?.connectionString && activeNWC.isConnected) {
await sendPayment(activeNWC, invoice);
return invoice;
}
// 2) Pay it with the best available rail, capturing the PREIMAGE where the
// rail returns one — that is the verifiable receipt.
let preimage: string | undefined;
if (webln) {
if (activeNWC?.connectionString && activeNWC.isConnected) {
const res = await sendPayment(activeNWC, invoice);
preimage = res?.preimage;
} else if (webln) {
let provider: WebLNProvider = webln;
if (typeof webln.enable === 'function') {
const enabled = (await webln.enable()) as WebLNProvider | undefined;
if (enabled) provider = enabled;
}
await provider.sendPayment(invoice);
return invoice;
const res = (await provider.sendPayment(invoice)) as { preimage?: string } | undefined;
preimage = res?.preimage;
} else {
const mint = mints[0];
if (coco && mint) {
const quote = await coco.quotes.createMeltQuote(mint.url, invoice);
const paid = (await coco.quotes.payMeltQuote(mint.url, quote.quote)) as
| Record<string, unknown>
| undefined;
preimage =
(paid?.payment_preimage as string | undefined) ??
(paid?.preimage as string | undefined);
} else {
throw new Error(
'No wallet connected. Add a Lightning wallet (NWC) in Settings, connect a WebLN wallet, or top up your Cashu balance.',
);
}
}
const mint = mints[0];
if (coco && mint) {
const quote = await coco.quotes.createMeltQuote(mint.url, invoice);
await coco.quotes.payMeltQuote(mint.url, quote.quote);
return invoice;
}
throw new Error(
'No wallet connected. Add a Lightning wallet (NWC) in Settings, connect a WebLN wallet, or top up your Cashu balance.',
);
return buildReceipt(invoice, preimage);
} finally {
setIsPaying(false);
}

View file

@ -3,6 +3,7 @@ import { useNostr } from '@nostrify/react';
import type { NostrEvent } from '@nostrify/nostrify';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { verifyZapReceipt } from '@/lib/zapReceipt';
import { verifyReceiptProof } from '@/lib/receipts';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { MARKETPLACE_ITEM_KIND } from '@/lib/types';
@ -97,6 +98,8 @@ export function useTradeAttestation() {
note,
rating,
requestCoord,
paymentProof,
depositProof,
}: {
/** The listing being attested. */
listing: { pubkey: string; identifier: string };
@ -111,6 +114,10 @@ export function useTradeAttestation() {
rating?: number;
/** The `31872` request coord this attestation fulfils, if any (spec `request` tag). */
requestCoord?: string;
/** A verifiable Lightning receipt: sha256(preimage) === paymentHash. */
paymentProof?: { preimage: string; paymentHash: string };
/** A refundable swap-deposit receipt (barter) — verified the same way. */
depositProof?: { preimage: string; paymentHash: string };
}) => {
if (!user) throw new Error('You must be logged in.');
if (counterparty === user.pubkey) throw new Error('Pick the other party, not yourself.');
@ -132,6 +139,13 @@ export function useTradeAttestation() {
tags.push(['rating', String(Math.round(rating))]);
}
if (requestCoord) tags.push(['request', requestCoord]);
// Verifiable receipts (anyone can check sha256(preimage) === payment_hash).
if (paymentProof?.preimage && paymentProof.paymentHash) {
tags.push(['proof', 'lnpreimage', paymentProof.preimage, paymentProof.paymentHash]);
}
if (depositProof?.preimage && depositProof.paymentHash) {
tags.push(['proof', 'deposit', depositProof.preimage, depositProof.paymentHash]);
}
const name = user.pubkey.slice(0, 8);
const content =
@ -263,16 +277,21 @@ export function useTradeReputation(pubkey: string | undefined) {
}
}
// Payment-VERIFIED tier: only where a `proof` names a zap receipt we can
// actually check. Collect them, fetch once, verify each.
// Payment-VERIFIED tier — count only proofs we can actually check:
// • a Lightning preimage (`sha256(preimage) === payment_hash`), and
// • a NIP-57 zap receipt (signature + recipient + amount, fetched below).
const zapProofs = new Map<string, string>(); // receiptId → expected seller
let paymentVerified = 0;
for (const ev of unique) {
const proof = ev.tags.find(([t]) => t === 'proof');
if (proof?.[1] !== 'zap' || !proof[2]) continue;
const coord = parseListingCoord(ev.tags.find(([t]) => t === 'a')?.[1]);
if (coord) zapProofs.set(proof[2], coord.pubkey);
if (!proof) continue;
if ((proof[1] === 'lnpreimage' || proof[1] === 'deposit') && proof[2] && proof[3]) {
if (verifyReceiptProof(proof[2], proof[3])) paymentVerified += 1;
} else if (proof[1] === 'zap' && proof[2]) {
const coord = parseListingCoord(ev.tags.find(([t]) => t === 'a')?.[1]);
if (coord) zapProofs.set(proof[2], coord.pubkey);
}
}
let paymentVerified = 0;
if (zapProofs.size > 0) {
const receipts = await nostr.query(
[{ kinds: [9735], ids: [...zapProofs.keys()], limit: 100 }],

View file

@ -65,3 +65,53 @@ export function getSatoshisFromBolt11(invoice: string): number {
const btc = multiplier ? amount * MULTIPLIERS[multiplier] : amount;
return Math.round(btc * SATS_PER_BTC);
}
/** bech32 charset (the data-part alphabet). */
const CHARSET = 'qpzry9x8gf2tvdw0s3jn54khce6mua7l';
/**
* Extract the **payment hash** (hex) from a BOLT11 invoice — the `p` tagged
* field. Used to verify a Lightning **preimage receipt**: `sha256(preimage)`
* must equal this hash. Dependency-free (we don't verify the bech32 checksum —
* a bad invoice just yields a hash that fails the preimage check).
*/
export function getPaymentHashFromBolt11(invoice: string): string | null {
try {
const lower = invoice.trim().toLowerCase();
const sep = lower.lastIndexOf('1');
if (sep < 1 || sep + 7 > lower.length) return null;
// Data words = everything after the separator, minus the 6-word checksum.
const data = lower.slice(sep + 1, lower.length - 6);
const words = [...data].map((c) => CHARSET.indexOf(c));
if (words.some((w) => w < 0)) return null;
// Tagged fields sit after the 35-bit timestamp (7 five-bit words).
let i = 7;
while (i + 3 <= words.length) {
const tag = words[i];
const len = (words[i + 1] << 5) | words[i + 2];
const start = i + 3;
if (start + len > words.length) break;
if (tag === 1) {
// `p` = payment hash: 32 bytes packed from `len` five-bit words.
const bytes: number[] = [];
let acc = 0;
let bits = 0;
for (let k = start; k < start + len; k += 1) {
acc = (acc << 5) | words[k];
bits += 5;
if (bits >= 8) {
bits -= 8;
bytes.push((acc >>> bits) & 0xff);
}
}
return bytes.slice(0, 32).map((b) => b.toString(16).padStart(2, '0')).join('');
}
i = start + len;
}
return null;
} catch {
return null;
}
}

96
src/lib/receipts.ts Normal file
View file

@ -0,0 +1,96 @@
/**
* 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';
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): void {
try {
const all = loadAll();
all[coord] = receipt;
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
} catch {
/* storage unavailable — the receipt just isn't carried forward */
}
}
export function getReceipt(coord: string): PaymentReceipt | undefined {
return loadAll()[coord];
}
export function clearReceipt(coord: string): void {
try {
const all = loadAll();
delete all[coord];
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
} catch {
/* ignore */
}
}