diff --git a/NIP.md b/NIP.md index 18ca1b5..3733c7d 100644 --- a/NIP.md +++ b/NIP.md @@ -931,13 +931,23 @@ If a spot hasn't been updated for 6+ months, any user can claim it by: ["d", ""], ["title", ""], // ... other original tags ... - ["claim-from", ""], + ["claim-from", "30818::"], ["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::`), 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::"]` · `["p",""]`. 3. **Finish (the claimant).** On approval the claimant re-publishes the spot - under their own key with `["claim-from",""]` and + under their own key with `["claim-from","30818::"]` and `["claim-reason","private-approved"]` — the existing claim mechanism. They are now the author/owner. diff --git a/src/components/PlatformFeePayment.tsx b/src/components/PlatformFeePayment.tsx index db22b3e..225073f 100644 --- a/src/components/PlatformFeePayment.tsx +++ b/src/components/PlatformFeePayment.tsx @@ -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' }); } }; diff --git a/src/components/SpotOwnership.tsx b/src/components/SpotOwnership.tsx index 178612d..d6b28eb 100644 --- a/src/components/SpotOwnership.tsx +++ b/src/components/SpotOwnership.tsx @@ -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({ diff --git a/src/hooks/useNWC.ts b/src/hooks/useNWC.ts index 402c494..8690f45 100644 --- a/src/hooks/useNWC.ts +++ b/src/hooks/useNWC.ts @@ -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')) { diff --git a/src/hooks/usePurchasePro.ts b/src/hooks/usePurchasePro.ts index e698e93..4a02426 100644 --- a/src/hooks/usePurchasePro.ts +++ b/src/hooks/usePurchasePro.ts @@ -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 */ } } diff --git a/src/hooks/useZaps.ts b/src/hooks/useZaps.ts index 6cb796e..9c8e110 100644 --- a/src/hooks/useZaps.ts +++ b/src/hooks/useZaps.ts @@ -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 diff --git a/src/i18n/batch4/feesUi.ts b/src/i18n/batch4/feesUi.ts index d987fe9..cba7b3a 100644 --- a/src/i18n/batch4/feesUi.ts +++ b/src/i18n/batch4/feesUi.ts @@ -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', diff --git a/src/i18n/en.ts b/src/i18n/en.ts index dd5c1c2..8f6d1af 100644 --- a/src/i18n/en.ts +++ b/src/i18n/en.ts @@ -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', diff --git a/src/i18n/es.ts b/src/i18n/es.ts index 4967950..f870be3 100644 --- a/src/i18n/es.ts +++ b/src/i18n/es.ts @@ -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', diff --git a/src/lib/types.ts b/src/lib/types.ts index 78d6e08..02f80a8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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, }; diff --git a/src/pages/SpotDetailPage.tsx b/src/pages/SpotDetailPage.tsx index c8237f0..abaad72 100644 --- a/src/pages/SpotDetailPage.tsx +++ b/src/pages/SpotDetailPage.tsx @@ -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() { )} + {/* Claim provenance — explains why a claimed spot may look like a duplicate */} + {spot.claimReason && ( + + +
+ +
+

{t('spotUi.claimedNotice')}

+

{t('spotUi.claimedNoticeDesc')}

+
+
+
+
+ )} + {/* Spot Ownership - Transfer / Claim */}