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:
parent
0f0e3f9aa6
commit
d566af851e
11 changed files with 114 additions and 8 deletions
16
NIP.md
16
NIP.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue