From e7c6bb1ecec9749cfc5b4b80111236f379951085 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Thu, 1 Oct 2026 17:25:16 +0700 Subject: [PATCH] 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. --- AGENTS.md | 23 ++++++ NIP.md | 15 ++-- src/components/ListingOffersPanel.tsx | 9 ++- src/components/ListingTradeActions.tsx | 40 +++++++++-- src/components/PayWithSats.tsx | 13 ++-- src/hooks/usePayPaymentPointer.ts | 46 +++++++----- src/hooks/useTradeAttestations.ts | 31 +++++++-- src/lib/bolt11.ts | 50 ++++++++++++++ src/lib/receipts.ts | 96 ++++++++++++++++++++++++++ 9 files changed, 279 insertions(+), 44 deletions(-) create mode 100644 src/lib/receipts.ts diff --git a/AGENTS.md b/AGENTS.md index 7d9e0af..a1abb60 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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::` 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 diff --git a/NIP.md b/NIP.md index aaa2f79..fb23ae2 100644 --- a/NIP.md +++ b/NIP.md @@ -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",""]` — a self-reported hash (a **claim**). `["proof","zap",""]` — 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",""]` (a self-typed hash). **Verifiable:** `["proof","lnpreimage","",""]` — anyone checks `sha256(preimage) === payment_hash`; `["proof","zap",""]` — a NIP-57 receipt (signature + recipient + amount). Both show as **payment-verified**. `["proof","deposit","",""]` — 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) diff --git a/src/components/ListingOffersPanel.tsx b/src/components/ListingOffersPanel.tsx index c6f3873..91a630f 100644 --- a/src/components/ListingOffersPanel.tsx +++ b/src/components/ListingOffersPanel.tsx @@ -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}`)); + }} /> )} diff --git a/src/components/ListingTradeActions.tsx b/src/components/ListingTradeActions.tsx index 1caf78f..a079d6b 100644 --- a/src/components/ListingTradeActions.tsx +++ b/src/components/ListingTradeActions.tsx @@ -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.', + }); + }} /> )} diff --git a/src/components/PayWithSats.tsx b/src/components/PayWithSats.tsx index 484a301..5e4328d 100644 --- a/src/components/PayWithSats.tsx +++ b/src/components/PayWithSats.tsx @@ -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); diff --git a/src/hooks/usePayPaymentPointer.ts b/src/hooks/usePayPaymentPointer.ts index 9c8072c..acb7f0b 100644 --- a/src/hooks/usePayPaymentPointer.ts +++ b/src/hooks/usePayPaymentPointer.ts @@ -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 => { + async ({ pointer, kind, amountSats }: PayPointerArgs): Promise => { 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 + | 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); } diff --git a/src/hooks/useTradeAttestations.ts b/src/hooks/useTradeAttestations.ts index ec92524..f36c294 100644 --- a/src/hooks/useTradeAttestations.ts +++ b/src/hooks/useTradeAttestations.ts @@ -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(); // 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 }], diff --git a/src/lib/bolt11.ts b/src/lib/bolt11.ts index 70b62a0..8463cd5 100644 --- a/src/lib/bolt11.ts +++ b/src/lib/bolt11.ts @@ -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; + } +} diff --git a/src/lib/receipts.ts b/src/lib/receipts.ts new file mode 100644 index 0000000..c262f64 --- /dev/null +++ b/src/lib/receipts.ts @@ -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", , ]` 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 { + try { + return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '{}') as Record; + } 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 */ + } +}