From dec8de6068884a9fcf73ab36141785cd5391a8e2 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 23 May 2026 15:58:05 +0200 Subject: [PATCH] bug-fix --- src/components/Note/Superchat.tsx | 2 +- src/components/Note/Zap.tsx | 3 +- src/components/ReplyNoteList/index.tsx | 13 --- .../TurnIntoSuperchatButton/index.tsx | 10 +-- src/hooks/usePaymentAttestationStatus.test.ts | 10 +-- src/hooks/usePaymentAttestationStatus.tsx | 87 +++---------------- src/lib/payment-attestation-cache.ts | 34 +++++++- 7 files changed, 57 insertions(+), 102 deletions(-) diff --git a/src/components/Note/Superchat.tsx b/src/components/Note/Superchat.tsx index 265ecca9..d78e6a5a 100644 --- a/src/components/Note/Superchat.tsx +++ b/src/components/Note/Superchat.tsx @@ -31,7 +31,6 @@ export default function Superchat({ variant?: SuperchatLayoutVariant }) { const { t } = useTranslation() - const { attested } = usePaymentAttestationStatus(event) const info = useMemo(() => getPaymentNotificationInfo(event), [event]) const paytoType = useMemo( () => (info?.payto ? parsePaytoTagType(info.payto) : 'unknown'), @@ -62,6 +61,7 @@ export default function Superchat({ } const { senderPubkey, recipientPubkey, comment } = info + const { attested } = usePaymentAttestationStatus(event, recipientPubkey) const hasThreadTarget = Boolean(targetEvent || referencedFetchId) const isNotification = variant === 'notification' const isProfileWall = variant === 'profileWall' diff --git a/src/components/Note/Zap.tsx b/src/components/Note/Zap.tsx index ad887de8..4112edde 100644 --- a/src/components/Note/Zap.tsx +++ b/src/components/Note/Zap.tsx @@ -54,7 +54,6 @@ export default function Zap({ }, [isEventZap, isProfileZap, targetEvent, zapInfo?.recipientPubkey]) const paytoType = useMemo(() => getSuperchatPaytoType(event), [event]) - const { attested } = usePaymentAttestationStatus(event) const { navigateToNote } = useSmartNoteNavigationOptional() const secondaryPage = useSecondaryPageOptional() const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url }) @@ -77,6 +76,8 @@ export default function Zap({ const attestationRecipientPubkey = actualRecipientPubkey ?? recipientPubkey ?? null + const { attested } = usePaymentAttestationStatus(event, attestationRecipientPubkey) + const openZapTarget = (e: MouseEvent) => { e.stopPropagation() if (isEventZap && zapInfo?.eventId) { diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 27f392d5..b495166b 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -51,7 +51,6 @@ import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-li import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { buildThreadInteractionFilters, buildThreadSuperchatPriorityFilters } from '@/lib/thread-interaction-req' import { - readKnownAttestedPaymentTargetsSync, resolveAttestedPaymentIdSet } from '@/lib/payment-attestation-cache' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' @@ -443,18 +442,6 @@ function ReplyNoteList({ return out.length ? out : undefined }, [duplicateWebPreviewCleanedUrlHints, rootInfo]) - useLayoutEffect(() => { - const pk = event.pubkey - if (!pk) return - const syncIds = readKnownAttestedPaymentTargetsSync(pk) - if (syncIds.size === 0) return - setAttestedPaymentIds((prev) => { - const next = new Set(prev) - for (const id of syncIds) next.add(id) - return next.size === prev.size ? prev : next - }) - }, [event.pubkey, event.id]) - useEffect(() => { const pk = event.pubkey if (!pk) return diff --git a/src/components/TurnIntoSuperchatButton/index.tsx b/src/components/TurnIntoSuperchatButton/index.tsx index 218c4b6a..51cfa62c 100644 --- a/src/components/TurnIntoSuperchatButton/index.tsx +++ b/src/components/TurnIntoSuperchatButton/index.tsx @@ -11,7 +11,6 @@ import { import { cn } from '@/lib/utils' import { requestProfileWallRefresh } from '@/hooks/useProfileWall' import { usePaymentAttestationStatus } from '@/hooks/usePaymentAttestationStatus' -import { markLocalAttestationTarget } from '@/lib/payment-attestation-cache' import { useNostr } from '@/providers/NostrProvider' import { Sparkles } from 'lucide-react' import { Event } from 'nostr-tools' @@ -96,12 +95,11 @@ function TurnIntoSuperchatButtonInner({ try { const draft = await createPaymentAttestationDraftEvent(event, { addClientTag: true }) const published = await publish(draft, { disableFallbacks: true }) - markLocalAttestationTarget(attestationRecipientPubkey, event.id) - if (published) { - markAttested(published) - } else { - markAttested({ ...draft, id: event.id, pubkey: attestationRecipientPubkey, sig: '' } as Event) + if (!published) { + toast.error(t('Failed to attest superchat')) + return } + markAttested(published) requestProfileWallRefresh(attestationRecipientPubkey) showSimplePublishSuccess(t('Superchat attested')) } catch (error) { diff --git a/src/hooks/usePaymentAttestationStatus.test.ts b/src/hooks/usePaymentAttestationStatus.test.ts index 1c4305d3..cca9f059 100644 --- a/src/hooks/usePaymentAttestationStatus.test.ts +++ b/src/hooks/usePaymentAttestationStatus.test.ts @@ -1,8 +1,5 @@ import { ExtendedKind } from '@/constants' -import { - markLocalAttestationTarget, - rememberPaymentAttestation -} from '@/lib/payment-attestation-cache' +import { rememberPaymentAttestation } from '@/lib/payment-attestation-cache' import { describe, expect, it } from 'vitest' import { isPaymentAttestationForTarget, readAttestedFromLocalSources } from './usePaymentAttestationStatus' import type { Event } from 'nostr-tools' @@ -32,11 +29,10 @@ describe('readAttestedFromLocalSources', () => { expect(result.attestationEvent?.id).toBe(attestation.id) }) - it('returns attested when durable local storage marks the target', () => { + it('does not treat durable local storage alone as verified attestation', () => { const otherTargetId = 'd'.repeat(64) - markLocalAttestationTarget(recipient, otherTargetId) const result = readAttestedFromLocalSources(otherTargetId, recipient) - expect(result.attested).toBe(true) + expect(result.attested).toBe(false) expect(result.attestationEvent).toBeNull() }) }) diff --git a/src/hooks/usePaymentAttestationStatus.tsx b/src/hooks/usePaymentAttestationStatus.tsx index b45018a7..83c0f296 100644 --- a/src/hooks/usePaymentAttestationStatus.tsx +++ b/src/hooks/usePaymentAttestationStatus.tsx @@ -6,13 +6,10 @@ import { import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' import { hydrateAttestationsForAuthor, - isLocallyMarkedAttested, loadPaymentAttestationLocal, peekCachedPaymentAttestation, - readKnownAttestedPaymentTargetsSync, refreshPaymentAttestationFromRelays, - rememberPaymentAttestationFromPublish, - resolveAttestedPaymentIdSet + rememberPaymentAttestationFromPublish } from '@/lib/payment-attestation-cache' import client from '@/services/client.service' import { Event as NostrEvent } from 'nostr-tools' @@ -38,6 +35,7 @@ export function isPaymentAttestationForTarget( return Boolean(attestedId && attestedId.toLowerCase() === targetEventId.trim().toLowerCase()) } +/** Sync read: only true when a verified attestation is already in the in-memory cache. */ export function readAttestedFromLocalSources( targetEventId: string | undefined, recipientPubkey: string | null @@ -45,25 +43,11 @@ export function readAttestedFromLocalSources( if (!targetEventId || !recipientPubkey) { return { attested: false, attestationEvent: null } } - const targetLower = targetEventId.trim().toLowerCase() const hit = peekCachedPaymentAttestation(targetEventId, recipientPubkey) - if (hit) { - return { attested: true, attestationEvent: hit } - } - if ( - isLocallyMarkedAttested(recipientPubkey, targetEventId) || - readKnownAttestedPaymentTargetsSync(recipientPubkey).has(targetLower) - ) { - return { attested: true, attestationEvent: null } + if (!hit || !isPaymentAttestationForTarget(hit, targetEventId, recipientPubkey)) { + return { attested: false, attestationEvent: null } } - return { attested: false, attestationEvent: null } -} - -function isTargetInAttestedSet( - attestedIds: ReadonlySet, - targetEventId: string -): boolean { - return attestedIds.has(targetEventId.trim().toLowerCase()) + return { attested: true, attestationEvent: hit } } export function usePaymentAttestationStatus( @@ -100,10 +84,11 @@ export function usePaymentAttestationStatus( const applyMatch = useCallback( (match: NostrEvent | undefined) => { - if (!match || !targetEvent?.id || !recipientPubkey) return - if (!isPaymentAttestationForTarget(match, targetEvent.id, recipientPubkey)) return + if (!match || !targetEvent?.id || !recipientPubkey) return false + if (!isPaymentAttestationForTarget(match, targetEvent.id, recipientPubkey)) return false setAttestationEvent(match) setAttested(true) + return true }, [recipientPubkey, targetEvent?.id] ) @@ -129,22 +114,6 @@ export function usePaymentAttestationStatus( setAttested(next.attested) }, [recipientPubkey, targetEvent?.id, targetId]) - const applyResolvedAttestation = useCallback( - (attestedIds: ReadonlySet) => { - if (!targetEvent?.id || !recipientPubkey) return false - if (!isTargetInAttestedSet(attestedIds, targetEvent.id)) return false - const cached = peekCachedPaymentAttestation(targetEvent.id, recipientPubkey) - if (cached) { - applyMatch(cached) - } else { - setAttestationEvent(null) - setAttested(true) - } - return true - }, - [applyMatch, recipientPubkey, targetEvent?.id] - ) - useEffect(() => { if (!recipientPubkey || !targetEvent?.id) return @@ -152,14 +121,8 @@ export function usePaymentAttestationStatus( void (async () => { await hydrateAttestationsForAuthor(recipientPubkey) if (cancelled) return - const next = readAttestedFromLocalSources(targetEvent.id, recipientPubkey) - if (!next.attested) return - if (next.attestationEvent) { - applyMatch(next.attestationEvent) - } else { - setAttestationEvent(null) - setAttested(true) - } + const cached = peekCachedPaymentAttestation(targetEvent.id, recipientPubkey) + if (cached && applyMatch(cached)) return })() return () => { @@ -177,14 +140,7 @@ export function usePaymentAttestationStatus( try { const local = await loadPaymentAttestationLocal(targetEvent.id, recipientPubkey, filter) if (cancelled) return - if (local) { - applyMatch(local) - return - } - - const attestedIds = await resolveAttestedPaymentIdSet(recipientPubkey) - if (cancelled) return - if (applyResolvedAttestation(attestedIds)) return + if (local && applyMatch(local)) return const relay = await refreshPaymentAttestationFromRelays( targetEvent.id, @@ -192,16 +148,9 @@ export function usePaymentAttestationStatus( filter ) if (cancelled) return - if (relay) { - applyMatch(relay) - return - } + if (relay && applyMatch(relay)) return - const afterRelay = await resolveAttestedPaymentIdSet(recipientPubkey) - if (cancelled) return - if (!applyResolvedAttestation(afterRelay)) { - clearAttested() - } + clearAttested() } catch { /* optional */ } finally { @@ -212,15 +161,7 @@ export function usePaymentAttestationStatus( return () => { cancelled = true } - }, [ - applyMatch, - applyResolvedAttestation, - clearAttested, - filter, - recipientPubkey, - targetEvent?.id, - targetId - ]) + }, [applyMatch, clearAttested, filter, recipientPubkey, targetEvent?.id, targetId]) useEffect(() => { if (!targetEvent?.id || !recipientPubkey) return diff --git a/src/lib/payment-attestation-cache.ts b/src/lib/payment-attestation-cache.ts index 412ea40a..81857d48 100644 --- a/src/lib/payment-attestation-cache.ts +++ b/src/lib/payment-attestation-cache.ts @@ -58,6 +58,27 @@ export function readKnownAttestedPaymentTargetsSync(recipientPubkey: string): Se return new Set(readLocalAttestedIds(recipientPubkey)) } +/** Drop durable local marks that are not backed by a cached kind 9741 attestation. */ +export function pruneUnverifiedLocalAttestationMarks(recipientPubkey: string): void { + const pk = normalizeHexPubkey(recipientPubkey) + if (!/^[0-9a-f]{64}$/.test(pk)) return + const local = readLocalAttestedIds(pk) + if (local.size === 0) return + const verified: string[] = [] + for (const id of local) { + const cached = peekCachedPaymentAttestation(id, pk) + if (cached?.kind === ExtendedKind.PAYMENT_ATTESTATION) { + verified.push(id) + } + } + if (verified.length === local.size) return + try { + localStorage.setItem(`${LOCAL_ATTESTED_KEY_PREFIX}${pk}`, JSON.stringify(verified)) + } catch { + /* quota */ + } +} + /** Attested payment target ids from local storage, IndexedDB, session, and optional relay batch. */ export async function resolveAttestedPaymentIdSet( recipientPubkey: string, @@ -66,7 +87,7 @@ export async function resolveAttestedPaymentIdSet( const pk = normalizeHexPubkey(recipientPubkey) if (!/^[0-9a-f]{64}$/.test(pk)) return new Set() - const out = new Set(readLocalAttestedIds(pk)) + const out = new Set() await hydrateAttestationsForAuthor(pk) const attestations: NostrEvent[] = [] @@ -93,6 +114,16 @@ export async function resolveAttestedPaymentIdSet( for (const id of buildAttestedPaymentIdSet(attestations, pk)) { out.add(id) } + + // Keep durable local marks only when they match a verified attestation target. + for (const id of readLocalAttestedIds(pk)) { + if (out.has(id)) continue + const cached = peekCachedPaymentAttestation(id, pk) + if (cached && cached.kind === ExtendedKind.PAYMENT_ATTESTATION) { + out.add(id) + } + } + return out } @@ -147,6 +178,7 @@ export async function hydrateAttestationsForAuthor(authorPubkey: string): Promis for (const attestation of sessionHits) { rememberPaymentAttestationFromPublish(attestation) } + pruneUnverifiedLocalAttestationMarks(pk) })().finally(() => { if (authorHydrateByPubkey.get(pk) === inflight) { authorHydrateByPubkey.delete(pk)