fix(marketplace): payment, attestation, and escrow safety
Next batch from MARKETPLACE_FLOW_AUDIT.md: - bolt11.ts: getBolt11Expiry / isBolt11Expired; usePayPaymentPointer refuses an expired invoice before paying (a lapsed offer invoice used to fail deep in the wallet with a confusing error). - PayWithSats shows unsupported pointer kinds (creq/clink) as unavailable up front instead of enabling a button that fails after the click. - useTradeAttestations: verify events (onlyVerified) and keep the newest per (author, kind, d), so a newer s=revoked supersedes a stale valid copy; useTradeReputation no longer trusts unverified relay events. - EscrowRequestDialog persists the minted token locally before publishing (lib/pendingEscrow.ts), so a failed send cannot strand the locked funds.
This commit is contained in:
parent
4b92759913
commit
077fb2d5d1
9 changed files with 201 additions and 28 deletions
|
|
@ -168,6 +168,7 @@ raw offer `status` (`ListingOffersPanel`), raw currency codes
|
||||||
|
|
||||||
## 6. Fixed in this pass
|
## 6. Fixed in this pass
|
||||||
|
|
||||||
|
First batch (correctness):
|
||||||
1. Grid "Mark as sold" now opens `MarkSoldDialog` (records the `31871`).
|
1. Grid "Mark as sold" now opens `MarkSoldDialog` (records the `31871`).
|
||||||
2. Offers accept: only the listing owner attaches a payment pointer (payee fix).
|
2. Offers accept: only the listing owner attaches a payment pointer (payee fix).
|
||||||
3. `advertiseEscrowKey` refuses to publish kind 0 before the profile has loaded.
|
3. `advertiseEscrowKey` refuses to publish kind 0 before the profile has loaded.
|
||||||
|
|
@ -176,21 +177,32 @@ raw offer `status` (`ListingOffersPanel`), raw currency codes
|
||||||
(Plus, earlier today: `useMarketplaceItem` moderation filter, offer CTA guards,
|
(Plus, earlier today: `useMarketplaceItem` moderation filter, offer CTA guards,
|
||||||
URL-addressable hub tabs, offers panel error branch.)
|
URL-addressable hub tabs, offers panel error branch.)
|
||||||
|
|
||||||
|
Second batch (payment, attestation, escrow):
|
||||||
|
6. **BOLT11 expiry** is checked before paying (`bolt11.ts` `getBolt11Expiry` /
|
||||||
|
`isBolt11Expired`), with a clear "invoice expired" error.
|
||||||
|
7. **Unsupported pointer kinds** (`creq` / `clink`) are shown as unavailable up
|
||||||
|
front in `PayWithSats` instead of enabling a button that fails after the click.
|
||||||
|
8. **Signature + revocation:** `useTradeReputation` and `useListingTrade` verify
|
||||||
|
events (`onlyVerified`) and keep the newest per (author, kind, d), so a newer
|
||||||
|
`s=revoked` supersedes a stale `valid` copy; the pending/request queries are
|
||||||
|
verified too.
|
||||||
|
9. **Escrow fund safety:** creation persists the minted token locally
|
||||||
|
(`lib/pendingEscrow.ts`) *before* publishing the request, so a failed send
|
||||||
|
cannot strand the locked funds, and the failure message says so. (Still a
|
||||||
|
mitigation: there is no re-send/recover UI yet.)
|
||||||
|
|
||||||
## 7. Recommended order
|
## 7. Recommended order
|
||||||
|
|
||||||
1. **P0 escrow safety:** make `EscrowRequestDialog` persist/attach the minted token
|
1. ~~P0 escrow fund-stranding: persist the token before publishing.~~ Done (item 9);
|
||||||
before publishing (or roll back and surface it) so a failed publish cannot
|
the remaining gap is a re-send/recover affordance for a saved pending escrow.
|
||||||
strand funds; persist the token so a reload cannot lose it. Native-only, but it
|
2. ~~P0 payee/pointer hardening + BOLT11 expiry.~~ Partly done (items 2, 6, 7);
|
||||||
is real money.
|
still open: cross-check `payment_hash` and reject non-SATS amounts paid as sats.
|
||||||
2. **P0 payee/pointer hardening:** verify `PayWithSats` only offers payment when a
|
3. **P1 correctness:** honor `escrow=required`, allow ask-seller on an active
|
||||||
seller-authored pointer exists and the payee matches the payee's key; add the
|
listing, make Mark-sold atomic or verify the attestation, account-scope and
|
||||||
BOLT11 expiry check and disable unsupported kinds before the click.
|
read back receipts, fix the `request` tag back-reference.
|
||||||
3. **P1 correctness:** attestation dedupe by author+kind+d (revocation), add
|
4. **P1 state:** NWC fallback (WebLN/Cashu on a normal failure) and empty-preimage
|
||||||
`onlyVerified` to `useTradeReputation`, honor `escrow=required`, allow
|
handling; outage states for trade/shipment/seller/reputation surfaces.
|
||||||
ask-seller on an active listing, make Mark-sold atomic or verify the attestation.
|
5. **P2:** native-only explanation on web, terminology consolidation, and the
|
||||||
4. **P1 state:** account-scope and read back receipts; fix the `request` tag;
|
untranslated values.
|
||||||
NWC fallback + empty-preimage handling.
|
|
||||||
5. **P2:** outage states everywhere, native-only explanation on web, terminology
|
|
||||||
consolidation, and the untranslated values.
|
|
||||||
|
|
||||||
Items tagged **[reported]** should be re-confirmed in source before a fix.
|
Items tagged **[reported]** should be re-confirmed in source before a fix.
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import { useEscrowPrefs } from '@/hooks/wallet/useEscrowPrefs';
|
||||||
import { useTradeAttestationRequest } from '@/hooks/useTradeAttestations';
|
import { useTradeAttestationRequest } from '@/hooks/useTradeAttestations';
|
||||||
import { useToast } from '@/hooks/useToast';
|
import { useToast } from '@/hooks/useToast';
|
||||||
import { ESCROW_MIN_LOCK_DAYS } from '@/lib/cashuEscrow';
|
import { ESCROW_MIN_LOCK_DAYS } from '@/lib/cashuEscrow';
|
||||||
|
import { savePendingEscrow, clearPendingEscrow } from '@/lib/pendingEscrow';
|
||||||
import { Loader2, Lock } from 'lucide-react';
|
import { Loader2, Lock } from 'lucide-react';
|
||||||
import { useTranslation } from '@/contexts/language';
|
import { useTranslation } from '@/contexts/language';
|
||||||
|
|
||||||
|
|
@ -62,14 +63,20 @@ export function EscrowRequestDialog({
|
||||||
}
|
}
|
||||||
|
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
let mintedToken: string | undefined;
|
||||||
try {
|
try {
|
||||||
const { token } = await escrow.createEscrow({ amountSats, sellerPubkey, lockDays, mintUrl: prefs.preferredMintUrl || undefined });
|
const { token } = await escrow.createEscrow({ amountSats, sellerPubkey, lockDays, mintUrl: prefs.preferredMintUrl || undefined });
|
||||||
|
mintedToken = token;
|
||||||
|
// Persist BEFORE publishing: if the request fails to send, the P2PK-locked
|
||||||
|
// funds stay recoverable from this device instead of being lost.
|
||||||
|
savePendingEscrow(listing, token, amountSats, lockDays);
|
||||||
await publishRequest({
|
await publishRequest({
|
||||||
listing,
|
listing,
|
||||||
attestor: sellerPubkey,
|
attestor: sellerPubkey,
|
||||||
cashuToken: token,
|
cashuToken: token,
|
||||||
note: t('marketUi.escrowRequest.requestNote', { amount: amountSats }),
|
note: t('marketUi.escrowRequest.requestNote', { amount: amountSats }),
|
||||||
});
|
});
|
||||||
|
clearPendingEscrow(listing);
|
||||||
toast({
|
toast({
|
||||||
title: t('marketUi.escrowRequest.created'),
|
title: t('marketUi.escrowRequest.created'),
|
||||||
description: t('marketUi.escrowRequest.createdBody', { amount: amountSats, days: lockDays, sellerName }),
|
description: t('marketUi.escrowRequest.createdBody', { amount: amountSats, days: lockDays, sellerName }),
|
||||||
|
|
@ -78,7 +85,9 @@ export function EscrowRequestDialog({
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({
|
toast({
|
||||||
title: t('marketUi.escrowRequest.createFailed'),
|
title: t('marketUi.escrowRequest.createFailed'),
|
||||||
description: e instanceof Error ? e.message : t('marketUi.escrowRequest.somethingWrong'),
|
description: mintedToken
|
||||||
|
? t('marketUi.escrowRequest.lockedNotSent')
|
||||||
|
: e instanceof Error ? e.message : t('marketUi.escrowRequest.somethingWrong'),
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,9 @@ export function PayWithSats({
|
||||||
: lud16
|
: lud16
|
||||||
? 'lnaddress'
|
? 'lnaddress'
|
||||||
: undefined;
|
: undefined;
|
||||||
|
// NUT-18 creq / clink are recognized but not payable yet — say so up front
|
||||||
|
// instead of enabling a button that fails after the click.
|
||||||
|
const unsupportedKind = effectiveKind === 'creq' || effectiveKind === 'clink';
|
||||||
|
|
||||||
// Reset transient state whenever the sheet is (re)opened.
|
// Reset transient state whenever the sheet is (re)opened.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -123,6 +126,11 @@ export function PayWithSats({
|
||||||
</div>
|
</div>
|
||||||
) : isSelf ? (
|
) : isSelf ? (
|
||||||
<p className="py-4 text-center text-sm text-muted-foreground">{t('marketUi.pay.ownListing')}</p>
|
<p className="py-4 text-center text-sm text-muted-foreground">{t('marketUi.pay.ownListing')}</p>
|
||||||
|
) : unsupportedKind ? (
|
||||||
|
<div className="space-y-2 py-4 text-center">
|
||||||
|
<AlertTriangle className="mx-auto h-8 w-8 text-amber-400" />
|
||||||
|
<p className="text-sm text-muted-foreground">{t('marketUi.pay.unsupported')}</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{error && (
|
{error && (
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ import { useNWC } from '@/hooks/useNWCContext';
|
||||||
import { useWallet } from '@/hooks/useWallet';
|
import { useWallet } from '@/hooks/useWallet';
|
||||||
import { useLightningAddress } from '@/hooks/useLightningAddress';
|
import { useLightningAddress } from '@/hooks/useLightningAddress';
|
||||||
import { useCashu } from '@/hooks/wallet/useCashu';
|
import { useCashu } from '@/hooks/wallet/useCashu';
|
||||||
import { getSatoshisFromBolt11 } from '@/lib/bolt11';
|
import { getSatoshisFromBolt11, isBolt11Expired } from '@/lib/bolt11';
|
||||||
import { buildReceipt, type PaymentReceipt } from '@/lib/receipts';
|
import { buildReceipt, type PaymentReceipt } from '@/lib/receipts';
|
||||||
import { assertInvoiceAmount } from '@/lib/verifyInvoice';
|
import { assertInvoiceAmount } from '@/lib/verifyInvoice';
|
||||||
import type { PaymentKind } from '@/hooks/useMarketplaceOffers';
|
import type { PaymentKind } from '@/hooks/useMarketplaceOffers';
|
||||||
|
|
@ -61,7 +61,13 @@ export function usePayPaymentPointer() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2) SECURITY: never auto-pay an invoice whose amount doesn't match the
|
// 2) Never pay an expired invoice — a lapsed one fails deep in the wallet
|
||||||
|
// with a confusing error, and a stale offer invoice is common.
|
||||||
|
if (isBolt11Expired(invoice)) {
|
||||||
|
throw new Error('This invoice has expired — ask the seller for a new one.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3) SECURITY: never auto-pay an invoice whose amount doesn't match the
|
||||||
// agreed price. A malicious seller can attach a raw BOLT11 or return an
|
// agreed price. A malicious seller can attach a raw BOLT11 or return an
|
||||||
// inflated invoice from their own LNURL server, and an NWC/WebLN wallet
|
// inflated invoice from their own LNURL server, and an NWC/WebLN wallet
|
||||||
// with a spend budget would pay it silently. `assertInvoiceAmount`
|
// with a spend budget would pay it silently. `assertInvoiceAmount`
|
||||||
|
|
@ -78,7 +84,7 @@ export function usePayPaymentPointer() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3) Pay it with the best available rail, capturing the PREIMAGE where the
|
// 4) Pay it with the best available rail, capturing the PREIMAGE where the
|
||||||
// rail returns one — that is the verifiable receipt.
|
// rail returns one — that is the verifiable receipt.
|
||||||
let preimage: string | undefined;
|
let preimage: string | undefined;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,19 @@ import { verifyZapReceipt } from '@/lib/zapReceipt';
|
||||||
import { verifyReceiptProof } from '@/lib/receipts';
|
import { verifyReceiptProof } from '@/lib/receipts';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { MARKETPLACE_ITEM_KIND } from '@/lib/types';
|
import { MARKETPLACE_ITEM_KIND } from '@/lib/types';
|
||||||
|
import { onlyVerified } from '@/lib/verifyEvents';
|
||||||
|
|
||||||
|
/** Keep only the newest event per (author, kind, d) — addressable semantics. */
|
||||||
|
function newestPerCoordinate(events: NostrEvent[]): NostrEvent[] {
|
||||||
|
const byKey = new Map<string, NostrEvent>();
|
||||||
|
for (const ev of events) {
|
||||||
|
const d = ev.tags.find(([t]) => t === 'd')?.[1] ?? '';
|
||||||
|
const key = `${ev.kind}:${ev.pubkey}:${d}`;
|
||||||
|
const current = byKey.get(key);
|
||||||
|
if (!current || ev.created_at > current.created_at) byKey.set(key, ev);
|
||||||
|
}
|
||||||
|
return [...byKey.values()];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trade attestations (completed deals) — built on the Attestations NIP
|
* Trade attestations (completed deals) — built on the Attestations NIP
|
||||||
|
|
@ -213,15 +226,17 @@ export function useTradeReputation(pubkey: string | undefined) {
|
||||||
if (!pubkey) return empty;
|
if (!pubkey) return empty;
|
||||||
|
|
||||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(12000)]);
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(12000)]);
|
||||||
const events = await nostr.query(
|
const events = onlyVerified(await nostr.query(
|
||||||
[
|
[
|
||||||
{ kinds: [TRADE_ATTESTATION_KIND], authors: [pubkey], limit: 200 },
|
{ kinds: [TRADE_ATTESTATION_KIND], authors: [pubkey], limit: 200 },
|
||||||
{ kinds: [TRADE_ATTESTATION_KIND], '#p': [pubkey], limit: 200 },
|
{ kinds: [TRADE_ATTESTATION_KIND], '#p': [pubkey], limit: 200 },
|
||||||
],
|
],
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
));
|
||||||
|
|
||||||
const unique = Array.from(new Map(events.map((e) => [e.id, e])).values()).filter(
|
// Newest per coordinate wins, so a newer `s=revoked` supersedes an older
|
||||||
|
// `valid` copy that a stale relay still serves.
|
||||||
|
const unique = newestPerCoordinate(events).filter(
|
||||||
(e) => isTradeAttestation(e) && e.tags.find(([t]) => t === 's')?.[1] !== 'revoked',
|
(e) => isTradeAttestation(e) && e.tags.find(([t]) => t === 's')?.[1] !== 'revoked',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -350,8 +365,8 @@ export function usePendingTradeConfirmations(viewerPubkey: string | undefined) {
|
||||||
|
|
||||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
|
||||||
const [naming, mine] = await Promise.all([
|
const [naming, mine] = await Promise.all([
|
||||||
nostr.query([{ kinds: [TRADE_ATTESTATION_KIND], '#p': [viewerPubkey], limit: 200 }], { signal }),
|
nostr.query([{ kinds: [TRADE_ATTESTATION_KIND], '#p': [viewerPubkey], limit: 200 }], { signal }).then(onlyVerified),
|
||||||
nostr.query([{ kinds: [TRADE_ATTESTATION_KIND], authors: [viewerPubkey], limit: 200 }], { signal }),
|
nostr.query([{ kinds: [TRADE_ATTESTATION_KIND], authors: [viewerPubkey], limit: 200 }], { signal }).then(onlyVerified),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const myCoords = new Set(
|
const myCoords = new Set(
|
||||||
|
|
@ -399,11 +414,11 @@ export function useListingTrade(
|
||||||
queryFn: async (c) => {
|
queryFn: async (c) => {
|
||||||
if (!coord) return { attestations: [] as NostrEvent[], iConfirmed: false, counterparties: [] as string[] };
|
if (!coord) return { attestations: [] as NostrEvent[], iConfirmed: false, counterparties: [] as string[] };
|
||||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
|
||||||
const events = await nostr.query(
|
const events = onlyVerified(await nostr.query(
|
||||||
[{ kinds: [TRADE_ATTESTATION_KIND], '#a': [coord], limit: 50 }],
|
[{ kinds: [TRADE_ATTESTATION_KIND], '#a': [coord], limit: 50 }],
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
));
|
||||||
const attestations = events.filter(
|
const attestations = newestPerCoordinate(events).filter(
|
||||||
(e) => isTradeAttestation(e) && e.tags.find(([t]) => t === 's')?.[1] !== 'revoked',
|
(e) => isTradeAttestation(e) && e.tags.find(([t]) => t === 's')?.[1] !== 'revoked',
|
||||||
);
|
);
|
||||||
const authorSet = new Set(attestations.map((e) => e.pubkey));
|
const authorSet = new Set(attestations.map((e) => e.pubkey));
|
||||||
|
|
@ -497,8 +512,8 @@ export function useIncomingTradeRequests(viewerPubkey: string | undefined) {
|
||||||
|
|
||||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
|
||||||
const [requests, mine] = await Promise.all([
|
const [requests, mine] = await Promise.all([
|
||||||
nostr.query([{ kinds: [TRADE_REQUEST_KIND], '#p': [viewerPubkey], limit: 200 }], { signal }),
|
nostr.query([{ kinds: [TRADE_REQUEST_KIND], '#p': [viewerPubkey], limit: 200 }], { signal }).then(onlyVerified),
|
||||||
nostr.query([{ kinds: [TRADE_ATTESTATION_KIND], authors: [viewerPubkey], limit: 200 }], { signal }),
|
nostr.query([{ kinds: [TRADE_ATTESTATION_KIND], authors: [viewerPubkey], limit: 200 }], { signal }).then(onlyVerified),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Requests the viewer already fulfilled (they attested that listing).
|
// Requests the viewer already fulfilled (they attested that listing).
|
||||||
|
|
|
||||||
|
|
@ -1893,6 +1893,7 @@ marketUi: {
|
||||||
paid: 'Paid!',
|
paid: 'Paid!',
|
||||||
paidBody: 'Ask the seller to confirm the trade — two confirmations build both reputations.',
|
paidBody: 'Ask the seller to confirm the trade — two confirmations build both reputations.',
|
||||||
noMethod: "{{sellerName}} hasn't set a payment method (no Lightning invoice or address). Message them to arrange payment.",
|
noMethod: "{{sellerName}} hasn't set a payment method (no Lightning invoice or address). Message them to arrange payment.",
|
||||||
|
unsupported: 'This payment code (a Cashu request) is not supported yet. Ask the seller for a Lightning invoice or address.',
|
||||||
ownListing: 'This is your own listing.',
|
ownListing: 'This is your own listing.',
|
||||||
paying: 'Paying…',
|
paying: 'Paying…',
|
||||||
pay: 'Pay',
|
pay: 'Pay',
|
||||||
|
|
@ -2077,6 +2078,7 @@ marketUi: {
|
||||||
amountLabel: 'Amount (sats)',
|
amountLabel: 'Amount (sats)',
|
||||||
daysLabel: 'Buyer refund after (days)',
|
daysLabel: 'Buyer refund after (days)',
|
||||||
daysHint: "After this many days you can reclaim the funds if the seller hasn't delivered.",
|
daysHint: "After this many days you can reclaim the funds if the seller hasn't delivered.",
|
||||||
|
lockedNotSent: 'Funds are locked in escrow and saved on this device, but the request did not send. Reopen the listing to retry. Do not pay again.',
|
||||||
nativeOnly: 'Cashu escrow is available in the SkateSpots app only (not the web build).',
|
nativeOnly: 'Cashu escrow is available in the SkateSpots app only (not the web build).',
|
||||||
lockAndRequest: 'Lock & request',
|
lockAndRequest: 'Lock & request',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1893,6 +1893,7 @@ marketUi: {
|
||||||
paid: '¡Pagado!',
|
paid: '¡Pagado!',
|
||||||
paidBody: 'Pide al vendedor que confirme el trato: dos confirmaciones construyen la reputación de ambos.',
|
paidBody: 'Pide al vendedor que confirme el trato: dos confirmaciones construyen la reputación de ambos.',
|
||||||
noMethod: '{{sellerName}} no ha configurado un método de pago (ni factura ni dirección Lightning). Escríbele para acordar el pago.',
|
noMethod: '{{sellerName}} no ha configurado un método de pago (ni factura ni dirección Lightning). Escríbele para acordar el pago.',
|
||||||
|
unsupported: 'Este código de pago (una solicitud Cashu) aún no es compatible. Pide al vendedor una factura o dirección Lightning.',
|
||||||
ownListing: 'Este es tu propio anuncio.',
|
ownListing: 'Este es tu propio anuncio.',
|
||||||
paying: 'Pagando…',
|
paying: 'Pagando…',
|
||||||
pay: 'Pagar',
|
pay: 'Pagar',
|
||||||
|
|
@ -2077,6 +2078,7 @@ marketUi: {
|
||||||
amountLabel: 'Importe (sats)',
|
amountLabel: 'Importe (sats)',
|
||||||
daysLabel: 'Reembolso del comprador tras (días)',
|
daysLabel: 'Reembolso del comprador tras (días)',
|
||||||
daysHint: 'Tras estos días puedes recuperar los fondos si el vendedor no ha entregado.',
|
daysHint: 'Tras estos días puedes recuperar los fondos si el vendedor no ha entregado.',
|
||||||
|
lockedNotSent: 'Los fondos están bloqueados en escrow y guardados en este dispositivo, pero la solicitud no se envió. Reabre el anuncio para reintentar. No pagues otra vez.',
|
||||||
nativeOnly: 'El depósito en garantía Cashu solo está disponible en la app de SkateSpots (no en la versión web).',
|
nativeOnly: 'El depósito en garantía Cashu solo está disponible en la app de SkateSpots (no en la versión web).',
|
||||||
lockAndRequest: 'Bloquear y solicitar',
|
lockAndRequest: 'Bloquear y solicitar',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -115,3 +115,51 @@ export function getPaymentHashFromBolt11(invoice: string): string | null {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Absolute expiry (unix seconds) of a BOLT11 invoice, or null when it cannot be
|
||||||
|
* parsed. Uses the `x` tagged field when present, else BOLT11's 3600s default.
|
||||||
|
*/
|
||||||
|
export function getBolt11Expiry(invoice: string): number | null {
|
||||||
|
try {
|
||||||
|
const lower = invoice.trim().toLowerCase();
|
||||||
|
const sep = lower.lastIndexOf('1');
|
||||||
|
if (sep < 1 || sep + 7 > lower.length) return null;
|
||||||
|
const data = lower.slice(sep + 1, lower.length - 6);
|
||||||
|
const words = [...data].map((c) => CHARSET.indexOf(c));
|
||||||
|
if (words.length < 7 || words.slice(0, 7).some((w) => w < 0)) return null;
|
||||||
|
|
||||||
|
// 35-bit timestamp = the first 7 five-bit words.
|
||||||
|
let timestamp = 0;
|
||||||
|
for (let i = 0; i < 7; i += 1) timestamp = timestamp * 32 + words[i];
|
||||||
|
|
||||||
|
let expiry = 3600;
|
||||||
|
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 === 6) {
|
||||||
|
// `x` = expiry, in seconds from the timestamp.
|
||||||
|
let value = 0;
|
||||||
|
for (let k = start; k < start + len; k += 1) value = value * 32 + words[k];
|
||||||
|
expiry = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
i = start + len;
|
||||||
|
}
|
||||||
|
return timestamp + expiry;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when a BOLT11 invoice is expired. Unparseable invoices return false. */
|
||||||
|
export function isBolt11Expired(
|
||||||
|
invoice: string,
|
||||||
|
nowSec: number = Math.floor(Date.now() / 1000),
|
||||||
|
): boolean {
|
||||||
|
const expiry = getBolt11Expiry(invoice);
|
||||||
|
return expiry !== null && nowSec > expiry;
|
||||||
|
}
|
||||||
|
|
|
||||||
71
src/lib/pendingEscrow.ts
Normal file
71
src/lib/pendingEscrow.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
/**
|
||||||
|
* Device-local record of an escrow token that was minted (funds locked) but not
|
||||||
|
* yet confirmed on a relay.
|
||||||
|
*
|
||||||
|
* WHY: escrow creation mints P2PK-locked proofs and then publishes a kind-31872
|
||||||
|
* request carrying the token. If the publish fails, the token lived only in an
|
||||||
|
* in-memory variable and the locked funds were unrecoverable. Persisting it here
|
||||||
|
* before the publish keeps the buyer able to recover/refund.
|
||||||
|
*
|
||||||
|
* The token is a bearer instrument, but it is P2PK-locked to the seller with the
|
||||||
|
* buyer's refund key — so storing it is safe and is exactly what protects the
|
||||||
|
* buyer if the send does not go through.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'skatespots:pending-escrow-v1';
|
||||||
|
|
||||||
|
export interface PendingEscrow {
|
||||||
|
token: string;
|
||||||
|
amountSats?: number;
|
||||||
|
lockDays?: number;
|
||||||
|
/** Unix seconds. */
|
||||||
|
at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Store = Record<string, PendingEscrow>;
|
||||||
|
|
||||||
|
function read(): Store {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return {};
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
return parsed && typeof parsed === 'object' ? (parsed as Store) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function write(store: Store): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(store));
|
||||||
|
} catch {
|
||||||
|
/* storage full / unavailable — best-effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function keyFor(listing: { pubkey: string; identifier: string }): string {
|
||||||
|
return `30402:${listing.pubkey}:${listing.identifier}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function savePendingEscrow(
|
||||||
|
listing: { pubkey: string; identifier: string },
|
||||||
|
token: string,
|
||||||
|
amountSats?: number,
|
||||||
|
lockDays?: number,
|
||||||
|
): void {
|
||||||
|
const store = read();
|
||||||
|
store[keyFor(listing)] = { token, amountSats, lockDays, at: Math.floor(Date.now() / 1000) };
|
||||||
|
write(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPendingEscrow(
|
||||||
|
listing: { pubkey: string; identifier: string },
|
||||||
|
): PendingEscrow | undefined {
|
||||||
|
return read()[keyFor(listing)];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPendingEscrow(listing: { pubkey: string; identifier: string }): void {
|
||||||
|
const store = read();
|
||||||
|
delete store[keyFor(listing)];
|
||||||
|
write(store);
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue