fix: H14 double-pay guard + H1 claim provenance

H14 (NWC double-pay): after the 15s pay() timeout, useNWC now polls
lookupInvoice to confirm settlement before reporting failure; if the status
stays unknown it throws a coded error (PAYMENT_STATUS_UNKNOWN) and the callers
that used to fall through to another rail (useZaps, usePurchasePro,
PlatformFeePayment) now STOP and tell the user to check their wallet — no
automatic second payment.

H1 (spot claim): claim-from now stores the ORIGINAL COORDINATE
(30818:pubkey:d) rather than an ambiguous bare pubkey; SkateSpot parses
claimedFrom/claimReason; a claimed listing shows a 'Claimed listing' notice so
the duplicate is understandable. NIP.md updated. Full reader-side supersede is
documented as the remaining design item (a wrong rule could hide real spots).
This commit is contained in:
SkateSpots 2026-10-03 09:34:00 +07:00
parent 0f0e3f9aa6
commit d566af851e
11 changed files with 114 additions and 8 deletions

16
NIP.md
View file

@ -931,13 +931,23 @@ If a spot hasn't been updated for 6+ months, any user can claim it by:
["d", "<same-d-tag>"],
["title", "<same-title>"],
// ... other original tags ...
["claim-from", "<original-owner-pubkey>"],
["claim-from", "30818:<original-owner-pubkey>:<original-d-tag>"],
["claim-reason", "abandoned"]
]
}
```
Note: Since Nostr is decentralized, the original spot remains. Clients should prefer the most recently updated version or allow users to choose.
`claim-from` is the **original coordinate** (`30818:<pubkey>:<d>`), not just the
author pubkey — a pubkey alone is ambiguous when an author has several spots.
(Legacy events that stored a bare pubkey are ignored by the parser.)
Note: Since Nostr is decentralized, the original spot remains. This is a known
limitation: an **unapproved** claim is a parallel listing, so the same physical
spot can appear twice until a client resolves the `claim-from` chain. **Reader-
side supersede is not implemented yet** — resolving it safely (only for an
approved private claim, or an aged original) is an open design item. For now a
claimed listing is shown with a **"Claimed listing"** notice so the duplicate is
at least understandable.
## Skate Tours (kind 30823)
@ -3487,7 +3497,7 @@ a stranger can never be auto-granted someone's pin.
`["a","30818:<owner>:<d>"]` · `["p","<claimant>"]`.
3. **Finish (the claimant).** On approval the claimant re-publishes the spot
under their own key with `["claim-from","<owner>"]` and
under their own key with `["claim-from","30818:<owner>:<d>"]` and
`["claim-reason","private-approved"]` — the existing claim mechanism. They are
now the author/owner.

View file

@ -3,6 +3,7 @@ import { Button } from '@/components/ui/button';
import { useToast } from '@/hooks/useToast';
import { useWallet } from '@/hooks/useWallet';
import { useNWC } from '@/hooks/useNWCContext';
import { PAYMENT_STATUS_UNKNOWN } from '@/hooks/useNWC';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import {
getInvoiceFromLightningAddress,
@ -206,8 +207,12 @@ export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: Pl
setPaymentState('success');
toast({ title: t('feesUi.paymentReceived'), description: t('feesUi.paymentReceivedDesc', { amount: amountSats.toLocaleString(), label: metaLabel }) });
setTimeout(onSuccess, 1200);
} catch {
} catch (err) {
setPaymentState('waiting');
if ((err as { code?: string })?.code === PAYMENT_STATUS_UNKNOWN) {
toast({ title: t('feesUi.autoPayFailed'), description: t('feesUi.payStatusUnknown'), variant: 'destructive' });
return;
}
toast({ title: t('feesUi.autoPayFailed'), description: t('feesUi.autoPayFailedDesc'), variant: 'destructive' });
}
};

View file

@ -180,7 +180,7 @@ export function SpotOwnership({ spot }: SpotOwnershipProps) {
eventTags.push([...tag]);
}
eventTags.push(['claim-from', spot.pubkey]);
eventTags.push(['claim-from', coord]);
eventTags.push(['claim-reason', reason]);
await publishEvent({

View file

@ -3,6 +3,36 @@ import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useToast } from '@/hooks/useToast';
import type { LN } from '@getalby/sdk';
/** Marks a payment whose final status could NOT be confirmed after a timeout. */
export const PAYMENT_STATUS_UNKNOWN = 'PAYMENT_STATUS_UNKNOWN';
/**
* Ask the wallet whether an invoice has settled. Used after a `pay()` timeout so
* we never report "failed" (and let the caller retry on another rail) when the
* payment may in fact have gone through.
*/
async function lookupInvoiceSettled(
connectionString: string,
invoice: string,
): Promise<{ settled: boolean; preimage?: string }> {
try {
const { nwc } = await import('@getalby/sdk');
const client = new nwc.NWCClient({ nostrWalletConnectUrl: connectionString });
try {
const res = (await client.lookupInvoice({ invoice })) as
| { state?: string; settled?: boolean; preimage?: string }
| undefined;
const state = String(res?.state ?? '').toLowerCase();
const settled = res?.settled === true || state === 'settled' || state === 'paid';
return { settled, preimage: res?.preimage };
} finally {
try { client.close(); } catch { /* ignore */ }
}
} catch {
return { settled: false };
}
}
export interface NWCConnection {
connectionString: string;
alias?: string;
@ -198,7 +228,18 @@ export function useNWCInternal() {
if (error instanceof Error) {
if (error.message.includes('timeout')) {
throw new Error('Payment timed out. Please try again.');
// The wallet may still settle it after our 15s cap. Confirm before
// reporting failure, so the caller never pays again on another rail.
for (let attempt = 0; attempt < 3; attempt++) {
const status = await lookupInvoiceSettled(connection.connectionString, invoice);
if (status.settled) return { preimage: status.preimage ?? '' };
if (attempt < 2) await new Promise((r) => setTimeout(r, 3000));
}
const unknown = new Error(
'Payment status unknown — it may have gone through. Check your wallet before trying again.',
) as Error & { code?: string };
unknown.code = PAYMENT_STATUS_UNKNOWN;
throw unknown;
} else if (error.message.includes('insufficient')) {
throw new Error('Insufficient balance in connected wallet.');
} else if (error.message.includes('invalid')) {

View file

@ -19,6 +19,7 @@ import { useCallback, useState } from 'react';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useNWC } from '@/hooks/useNWCContext';
import { PAYMENT_STATUS_UNKNOWN } from '@/hooks/useNWC';
import { useWallet } from '@/hooks/useWallet';
import { useRelays } from '@/lib/relays';
import { useSendDirectMessage } from '@/hooks/useDirectMessages';
@ -172,7 +173,13 @@ export function usePurchasePro() {
await sendConfirmationDM(tier, expiry);
setResult({ invoice: null, state: 'paid', error: null });
return;
} catch {
} catch (err) {
// Unknown status: the payment may have gone through — do NOT retry on
// another rail. Surface the invoice so the user can verify.
if ((err as { code?: string })?.code === PAYMENT_STATUS_UNKNOWN) {
setResult({ invoice, state: 'awaiting_payment', error: t('feesUi.payStatusUnknown') });
return;
}
/* fall through to WebLN / manual */
}
}

View file

@ -4,7 +4,7 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useRelays } from '@/lib/relays';
import { useToast } from '@/hooks/useToast';
import { useNWC } from '@/hooks/useNWCContext';
import type { NWCConnection } from '@/hooks/useNWC';
import { PAYMENT_STATUS_UNKNOWN, type NWCConnection } from '@/hooks/useNWC';
import { nip57 } from 'nostr-tools';
import type { Event } from 'nostr-tools';
import { getSatoshisFromBolt11 } from '@/lib/bolt11';
@ -270,6 +270,17 @@ export function useZaps(
onZapSuccess?.();
return;
} catch (nwcError) {
// UNKNOWN status means the payment may have gone through — never
// fall through to another rail (that would double-pay).
if ((nwcError as { code?: string })?.code === PAYMENT_STATUS_UNKNOWN) {
setIsZapping(false);
toast({
title: 'Payment status unknown',
description: 'Your wallet may still be settling this zap. Check your wallet before retrying — do not pay twice.',
variant: 'destructive',
});
return;
}
console.error('NWC payment failed, falling back:', nwcError);
// Show specific NWC error to user for debugging

View file

@ -36,6 +36,7 @@ const feesUi = {
paymentReceivedDesc: '{{amount}} sats — {{label}} is now active.',
autoPayFailed: 'Auto-pay failed',
autoPayFailedDesc: 'Pay manually using the QR or invoice below.',
payStatusUnknown: 'Payment status unknown — the payment may have gone through. Check your wallet before paying again.',
invoiceCopied: 'Invoice copied!',
activeBang: '{{label}} Active!',
chooseDuration: 'Choose duration',
@ -102,6 +103,7 @@ const feesUi = {
paymentReceivedDesc: '{{amount}} sats — {{label}} ya está activo.',
autoPayFailed: 'Falló el pago automático',
autoPayFailedDesc: 'Paga manualmente con el QR o la factura de abajo.',
payStatusUnknown: 'Estado del pago desconocido: puede que se haya cobrado. Comprueba tu cartera antes de volver a pagar.',
invoiceCopied: '¡Factura copiada!',
activeBang: '¡{{label}} activo!',
chooseDuration: 'Elige duración',

View file

@ -997,6 +997,8 @@ spotUi: {
loginRequired: 'Log in to claim this private spot, or to request ownership.',
},
abandonedDesc: 'Last updated {{time}}. If the original owner is inactive, you can claim this spot to maintain and improve it.',
claimedNotice: 'Claimed listing',
claimedNoticeDesc: 'This spot was re-published by a new owner after a claim. The original listing may still exist separately.',
abandonedLoginDesc: 'Last updated {{time}}. Log in to claim and maintain this spot.',
accessDeniedDesc: 'You can only edit spots that you created.',
accessDeniedTitle: 'Access Denied',

View file

@ -997,6 +997,8 @@ spotUi: {
loginRequired: 'Inicia sesión para reclamar este spot privado o solicitar su propiedad.',
},
abandonedDesc: 'Actualizado por última vez {{time}}. Si el dueño original está inactivo, puedes reclamar este spot para mantenerlo y mejorarlo.',
claimedNotice: 'Anuncio reclamado',
claimedNoticeDesc: 'Un nuevo propietario volvió a publicar este spot tras reclamarlo. El anuncio original puede seguir existiendo aparte.',
abandonedLoginDesc: 'Actualizado por última vez {{time}}. Inicia sesión para reclamar y mantener este spot.',
accessDeniedDesc: 'Solo puedes editar los spots que has creado.',
accessDeniedTitle: 'Acceso denegado',

View file

@ -705,6 +705,10 @@ export interface SkateSpot {
shelter?: 'outdoor' | 'partial' | 'covered' | 'indoor';
contactInfo?: string;
createdAt: number;
/** If this spot is a claimed version of another, the ORIGINAL coordinate (`30818:pubkey:d`). */
claimedFrom?: string;
/** Claim provenance: `abandoned` | `transfer` | `private-approved`. */
claimReason?: string;
event: NostrEvent;
}
@ -841,6 +845,8 @@ export function parseSkateSpot(event: NostrEvent): SkateSpot | null {
: undefined;
const contactInfo = event.tags.find(([name]) => name === 'contact')?.[1];
const claimedFromRaw = event.tags.find(([name]) => name === 'claim-from')?.[1];
const claimReason = event.tags.find(([name]) => name === 'claim-reason')?.[1];
return {
id: event.id,
@ -857,6 +863,10 @@ export function parseSkateSpot(event: NostrEvent): SkateSpot | null {
accessType,
shelter,
contactInfo: clampStr(contactInfo, 200),
// Only a coordinate is a usable provenance pointer; legacy events stored the
// original author's pubkey, which is too ambiguous to resolve — ignore it.
claimedFrom: claimedFromRaw?.startsWith(`${SKATE_SPOT_KIND}:`) ? claimedFromRaw : undefined,
claimReason: clampStr(claimReason, 40),
createdAt: event.created_at,
event,
};

View file

@ -69,6 +69,7 @@ import {
ShieldQuestion,
BadgeCheck,
ShieldAlert,
Info,
} from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { ReportDialog } from '@/components/ReportDialog';
@ -647,6 +648,21 @@ export default function SpotDetailPage() {
</Card>
)}
{/* Claim provenance — explains why a claimed spot may look like a duplicate */}
{spot.claimReason && (
<Card className="bg-blue-500/10 border-2 border-blue-500/30">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Info className="h-5 w-5 text-blue-400 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-bold text-blue-300 uppercase">{t('spotUi.claimedNotice')}</p>
<p className="text-xs text-gray-400">{t('spotUi.claimedNoticeDesc')}</p>
</div>
</div>
</CardContent>
</Card>
)}
{/* Spot Ownership - Transfer / Claim */}
<SpotOwnership spot={spot} />