()
@@ -1163,25 +1243,21 @@ function ReplyNoteList({
addReplies(mergedForUi)
const recipientPubkey = event.pubkey
- if (recipientPubkey && relayUrlsForThreadReq.length > 0) {
- void client
- .fetchEvents(
- relayUrlsForThreadReq,
- {
- kinds: [ExtendedKind.PAYMENT_ATTESTATION],
- authors: [recipientPubkey],
- limit: 500
- },
- {
- cache: true,
- eoseTimeout: 4500,
- globalTimeout: 12_000,
- foreground: statsForeground
- }
- )
- .then((attestations) => {
+ threadRelayUrlsRef.current = relayUrlsForThreadReq
+ if (recipientPubkey) {
+ void fetchPaymentAttestationsForRecipient(recipientPubkey, relayUrlsForThreadReq, {
+ foreground: statsForeground
+ })
+ .then(async (attestations) => {
+ if (fetchGeneration !== replyFetchGenRef.current) return
+ const attestedIds = buildAttestedPaymentIdSet(attestations, recipientPubkey)
+ setAttestedPaymentIds(attestedIds)
+ const targets = await hydrateAttestedSuperchatTargets(
+ attestedIds,
+ relayUrlsForThreadReq
+ )
if (fetchGeneration !== replyFetchGenRef.current) return
- setAttestedPaymentIds(buildAttestedPaymentIdSet(attestations, recipientPubkey))
+ if (targets.length > 0) addReplies(targets)
})
.catch(() => {
/* attestations optional */
diff --git a/src/components/TurnIntoSuperchatButton/index.tsx b/src/components/TurnIntoSuperchatButton/index.tsx
index 16f761be..7537ac42 100644
--- a/src/components/TurnIntoSuperchatButton/index.tsx
+++ b/src/components/TurnIntoSuperchatButton/index.tsx
@@ -4,9 +4,12 @@ import { LoginRequiredError } from '@/lib/nostr-errors'
import { showSimplePublishSuccess } from '@/lib/publishing-feedback'
import {
getSuperchatAttestationTargetKindValue,
- isAttestableSuperchatPayment
+ getSuperchatPaymentRecipientPubkey,
+ isAttestableSuperchatPayment,
+ isIncomingPaymentNotificationOrZapReceipt
} from '@/lib/superchat'
import { cn } from '@/lib/utils'
+import { requestProfileWallRefresh } from '@/hooks/useProfileWall'
import { usePaymentAttestationStatus } from '@/hooks/usePaymentAttestationStatus'
import { useNostr } from '@/providers/NostrProvider'
import { Sparkles } from 'lucide-react'
@@ -25,17 +28,41 @@ export default function TurnIntoSuperchatButton({
/** Full-width call-to-action styling for note cards. */
prominent?: boolean
}) {
- const { t } = useTranslation()
- const { pubkey, publish, checkLogin } = useNostr()
- const { attested, checking, recipientPubkey } = usePaymentAttestationStatus(event)
- const [publishing, setPublishing] = useState(false)
+ const { pubkey } = useNostr()
- if (!isAttestableSuperchatPayment(event) || !getSuperchatAttestationTargetKindValue(event)) {
+ if (
+ !isAttestableSuperchatPayment(event) ||
+ !getSuperchatAttestationTargetKindValue(event) ||
+ !pubkey ||
+ !isIncomingPaymentNotificationOrZapReceipt(event, pubkey)
+ ) {
return null
}
- if (!pubkey || !recipientPubkey || recipientPubkey.toLowerCase() !== pubkey.toLowerCase()) {
+
+ return (
+
+ )
+}
+
+function TurnIntoSuperchatButtonInner({
+ event,
+ className,
+ prominent = false
+}: {
+ event: Event
+ className?: string
+ prominent?: boolean
+}) {
+ const { t } = useTranslation()
+ const { publish, checkLogin } = useNostr()
+ const recipientPubkey = getSuperchatPaymentRecipientPubkey(event)
+ const { attested, checking } = usePaymentAttestationStatus(event)
+ const [publishing, setPublishing] = useState(false)
+
+ if (!recipientPubkey) {
return null
}
+
if (attested) {
return (
(null)
const [checking, setChecking] = useState(false)
const recipientPubkey = targetEvent ? getSuperchatPaymentRecipientPubkey(targetEvent) : null
@@ -17,6 +18,7 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined)
useEffect(() => {
setAttested(false)
+ setAttestationEvent(null)
if (!targetEvent?.id || !recipientPubkey) return
let cancelled = false
@@ -35,8 +37,9 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined)
)
.then((attestations) => {
if (cancelled) return
- const ids = buildAttestedPaymentIdSet(attestations, recipientPubkey)
- setAttested(ids.has(targetEvent.id.toLowerCase()))
+ const match = findPaymentAttestationForTarget(attestations, targetEvent.id, recipientPubkey)
+ setAttestationEvent(match ?? null)
+ setAttested(Boolean(match))
})
.catch(() => {
/* optional */
@@ -60,6 +63,7 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined)
const attestedId = getPaymentAttestationTargetId(evt)
if (attestedId?.toLowerCase() === targetEvent.id.toLowerCase()) {
setAttested(true)
+ setAttestationEvent(evt)
}
}
@@ -67,5 +71,5 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined)
return () => client.removeEventListener('newEvent', handleAttestation)
}, [targetEvent?.id, recipientPubkey])
- return { attested, checking, recipientPubkey }
+ return { attested, attestationEvent, checking, recipientPubkey }
}
diff --git a/src/hooks/useProfileWall.tsx b/src/hooks/useProfileWall.tsx
index bf60e8e2..ae60e22e 100644
--- a/src/hooks/useProfileWall.tsx
+++ b/src/hooks/useProfileWall.tsx
@@ -19,10 +19,11 @@ import {
type ResolvedProfileBadge
} from '@/lib/nip58-profile-badges'
import { isDirectProfileWallComment } from '@/lib/profile-wall-comments'
-import { filterAttestedProfileWallSuperchats, isProfileWallPaymentNotification } from '@/lib/superchat'
+import { filterAttestedProfileWallSuperchats, getPaymentAttestationTargetId } from '@/lib/superchat'
import { isValidPubkey, userIdToPubkey } from '@/lib/pubkey'
import { normalizeAnyRelayUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
+import type { TSubRequestFilter } from '@/types'
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import client, { replaceableEventService } from '@/services/client.service'
import { ReplaceableEventService } from '@/services/client-replaceable-events.service'
@@ -90,10 +91,15 @@ function normalizeWallRefreshPubkey(pubkey: string): string | null {
return /^[0-9a-f]{64}$/.test(pk) ? pk : null
}
-/** Invalidate in-memory wall cache and schedule a badge re-fetch (avoids sync window events during React updates). */
+/** Invalidate in-memory wall cache and schedule a re-fetch when a profile wall hook is mounted. */
export function requestProfileWallRefresh(pubkey: string): void {
const pk = normalizeWallRefreshPubkey(pubkey)
if (!pk) return
+ for (const key of wallCacheByKey.keys()) {
+ if (key.startsWith(`${pk}-`) || key.startsWith(`${pubkey.trim().toLowerCase()}-`)) {
+ wallCacheByKey.delete(key)
+ }
+ }
const listeners = wallRefreshListenersByPubkey.get(pk)
if (!listeners?.size) return
for (const listener of listeners) listener()
@@ -116,7 +122,7 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
const cached = wallCacheByKey.get(cacheKey)
const hasUsefulWallCache =
!!cached &&
- cached.badges.length > 0 &&
+ (cached.badges.length > 0 || (cached.superchats?.length ?? 0) > 0) &&
Date.now() - cached.lastUpdated < CACHE_DURATION
const pkNormForHydrate = useMemo(() => userIdToPubkey(pubkey) || pubkey, [pubkey])
@@ -178,6 +184,23 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
const pk = normalizeWallRefreshPubkey(pkNormForHydrate)
if (!pk) return
+ const onWallPaymentEvent = (data: globalThis.Event) => {
+ const evt = (data as CustomEvent).detail
+ if (!evt) return
+ if (evt.kind === ExtendedKind.PAYMENT_ATTESTATION) {
+ if (evt.pubkey.toLowerCase() !== pk) return
+ if (!getPaymentAttestationTargetId(evt)) return
+ } else if (evt.kind === ExtendedKind.PAYMENT_NOTIFICATION) {
+ const recipient = evt.tags.find((t) => t[0] === 'p')?.[1]
+ if (!recipient || recipient.toLowerCase() !== pk) return
+ } else {
+ return
+ }
+ bumpWallRefetch()
+ }
+
+ client.addEventListener('newEvent', onWallPaymentEvent)
+
const listeners = wallRefreshListenersByPubkey.get(pk) ?? new Set()
listeners.add(scheduleManualWallRefetch)
wallRefreshListenersByPubkey.set(pk, listeners)
@@ -192,6 +215,7 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
onAuthorReplaceablesRefreshed
)
return () => {
+ client.removeEventListener('newEvent', onWallPaymentEvent)
listeners.delete(scheduleManualWallRefetch)
if (listeners.size === 0) {
wallRefreshListenersByPubkey.delete(pk)
@@ -218,7 +242,7 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
// Do not reuse empty cache (transient abort when secondary panel opens used to cache [] for 5m).
if (
mem &&
- mem.badges.length > 0 &&
+ (mem.badges.length > 0 || (mem.superchats?.length ?? 0) > 0) &&
Date.now() - mem.lastUpdated < CACHE_DURATION &&
refreshToken === 0
) {
@@ -299,21 +323,41 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
}
setIsLoading(false)
- // --- Wall comments (kind 1111) and attested superchats (kind 9740) ---
+ // --- Wall comments (kind 1111) and attested superchats (9735 / 9740 + 9741) ---
let wallComments: Event[] = []
let wallSuperchats: Event[] = []
- const profileId = profileEventId?.trim().toLowerCase()
- if (profileId && /^[0-9a-f]{64}$/.test(profileId) && relayUrls.length > 0) {
+ const profileId =
+ profileEventId?.trim().toLowerCase() && /^[0-9a-f]{64}$/.test(profileEventId.trim())
+ ? profileEventId.trim().toLowerCase()
+ : undefined
+ if (relayUrls.length > 0) {
const profileCoord = getReplaceableCoordinate(kinds.Metadata, pkNorm, '')
const filters: Filter[] = [
- { kinds: [ExtendedKind.COMMENT], '#e': [profileId], limit: 200 },
- { kinds: [ExtendedKind.COMMENT], '#a': [profileCoord], limit: 200 },
{ kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#p': [pkNorm], limit: 200 },
- { kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#e': [profileId], limit: 200 },
- { kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#a': [profileCoord], limit: 200 },
+ { kinds: [kinds.Zap], '#p': [pkNorm], limit: 200 },
{ kinds: [ExtendedKind.PAYMENT_ATTESTATION], authors: [pkNorm], limit: 500 }
]
+ if (profileId) {
+ filters.unshift(
+ { kinds: [ExtendedKind.COMMENT], '#e': [profileId], limit: 200 },
+ { kinds: [ExtendedKind.COMMENT], '#a': [profileCoord], limit: 200 }
+ )
+ filters.push(
+ { kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#e': [profileId], limit: 200 },
+ { kinds: [ExtendedKind.PAYMENT_NOTIFICATION], '#a': [profileCoord], limit: 200 },
+ { kinds: [kinds.Zap], '#e': [profileId], limit: 200 }
+ )
+ }
const pool = new Map()
+ try {
+ const localMatches = await client.getLocalFeedEvents(
+ filters.map((filter) => ({ urls: [], filter: filter as TSubRequestFilter })),
+ { maxMatches: 800 }
+ )
+ for (const e of localMatches) pool.set(e.id, e)
+ } catch {
+ /* ignore */
+ }
try {
const rows = await Promise.all(
filters.map((filter) =>
@@ -332,26 +376,29 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
/* ignore */
}
- wallComments = [...pool.values()]
- .filter(
- (e) =>
- e.kind === ExtendedKind.COMMENT &&
- !isEventDeletedRef.current(e) &&
- isDirectProfileWallComment(e, profileId, pkNorm)
- )
- .sort((a, b) => b.created_at - a.created_at)
+ if (profileId) {
+ wallComments = [...pool.values()]
+ .filter(
+ (e) =>
+ e.kind === ExtendedKind.COMMENT &&
+ !isEventDeletedRef.current(e) &&
+ isDirectProfileWallComment(e, profileId, pkNorm)
+ )
+ .sort((a, b) => b.created_at - a.created_at)
+ }
- const paymentNotifications = [...pool.values()].filter(
+ const paymentEvents = [...pool.values()].filter(
(e) =>
- e.kind === ExtendedKind.PAYMENT_NOTIFICATION &&
- !isEventDeletedRef.current(e) &&
- isProfileWallPaymentNotification(e, pkNorm, profileId)
+ (e.kind === ExtendedKind.PAYMENT_NOTIFICATION ||
+ e.kind === kinds.Zap ||
+ e.kind === ExtendedKind.ZAP_RECEIPT) &&
+ !isEventDeletedRef.current(e)
)
const attestations = [...pool.values()].filter(
(e) => e.kind === ExtendedKind.PAYMENT_ATTESTATION
)
wallSuperchats = filterAttestedProfileWallSuperchats(
- paymentNotifications,
+ paymentEvents,
attestations,
pkNorm,
profileId
diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts
index 441d3a14..6c6047d7 100644
--- a/src/i18n/locales/en.ts
+++ b/src/i18n/locales/en.ts
@@ -84,6 +84,9 @@ export default {
"Copy user ID": "Copy user ID",
"Send public message": "Send public message",
"View raw event": "View raw event",
+ "View attestation": "View attestation",
+ "Payment attestation": "Payment attestation",
+ "Raw Event": "Raw Event",
"Edit this event": "Edit this event",
"Clone or fork this event": "Clone or fork this event",
"Event kind": "Event kind",
diff --git a/src/lib/event.ts b/src/lib/event.ts
index 86454ab7..fc0baeb9 100644
--- a/src/lib/event.ts
+++ b/src/lib/event.ts
@@ -209,8 +209,12 @@ export function getParentETag(event?: Event) {
return event.tags.find(tagNameEquals('e')) ?? event.tags.find(tagNameEquals('E'))
}
- // Kind 9735: zapped note id is on `e` / `E` (or addressable target on `a` / `A`)
- if (event.kind === kinds.Zap) {
+ // Kind 9735 / 9740: referenced note id is on `e` / `E` (or addressable target on `a` / `A`).
+ if (
+ event.kind === kinds.Zap ||
+ event.kind === ExtendedKind.ZAP_RECEIPT ||
+ event.kind === ExtendedKind.PAYMENT_NOTIFICATION
+ ) {
const firstHex = getFirstHexEventIdFromETags(event.tags)
if (firstHex) {
return (
@@ -242,7 +246,11 @@ export function getParentETag(event?: Event) {
export function getParentATag(event?: Event) {
if (!event) return undefined
- if (event.kind === kinds.Zap) {
+ if (
+ event.kind === kinds.Zap ||
+ event.kind === ExtendedKind.ZAP_RECEIPT ||
+ event.kind === ExtendedKind.PAYMENT_NOTIFICATION
+ ) {
return event.tags.find(tagNameEquals('a')) ?? event.tags.find(tagNameEquals('A'))
}
if (
diff --git a/src/lib/payment-superchat-idb.ts b/src/lib/payment-superchat-idb.ts
new file mode 100644
index 00000000..caf56e87
--- /dev/null
+++ b/src/lib/payment-superchat-idb.ts
@@ -0,0 +1,76 @@
+import { ExtendedKind } from '@/constants'
+import { normalizeReplaceableCoordinateString } from '@/lib/event'
+import { getPaymentAttestationTargetId, getPaymentNotificationInfo } from '@/lib/superchat'
+import type { Event } from 'nostr-tools'
+
+export type PaymentNotificationIdbRow = {
+ key: string
+ value: Event
+ addedAt: number
+ recipientPubkey: string
+ referencedEventId: string
+ referencedCoordinate: string
+}
+
+export type PaymentAttestationIdbRow = {
+ key: string
+ value: Event
+ addedAt: number
+ authorPubkey: string
+ targetEventId: string
+}
+
+function normalizeHexId(id: string | undefined): string {
+ const t = id?.trim().toLowerCase() ?? ''
+ return /^[0-9a-f]{64}$/.test(t) ? t : ''
+}
+
+function normalizePubkey(pk: string | undefined): string {
+ const t = pk?.trim().toLowerCase() ?? ''
+ return /^[0-9a-f]{64}$/.test(t) ? t : ''
+}
+
+export function paymentNotificationIdbRowFromEvent(ev: Event): PaymentNotificationIdbRow | null {
+ if (ev.kind !== ExtendedKind.PAYMENT_NOTIFICATION) return null
+ const info = getPaymentNotificationInfo(ev)
+ if (!info?.recipientPubkey) return null
+ const key = normalizeHexId(ev.id)
+ if (!key) return null
+
+ const clean = { ...ev } as Event
+ delete (clean as { relayStatuses?: unknown }).relayStatuses
+ clean.id = key
+
+ return {
+ key,
+ value: clean,
+ addedAt: Date.now(),
+ recipientPubkey: normalizePubkey(info.recipientPubkey),
+ referencedEventId: normalizeHexId(info.referencedEventId),
+ referencedCoordinate: info.referencedCoordinate
+ ? normalizeReplaceableCoordinateString(info.referencedCoordinate)
+ : ''
+ }
+}
+
+export function paymentAttestationIdbRowFromEvent(ev: Event): PaymentAttestationIdbRow | null {
+ if (ev.kind !== ExtendedKind.PAYMENT_ATTESTATION) return null
+ const targetEventId = getPaymentAttestationTargetId(ev)
+ if (!targetEventId) return null
+ const authorPubkey = normalizePubkey(ev.pubkey)
+ if (!authorPubkey) return null
+ const key = normalizeHexId(ev.id)
+ if (!key) return null
+
+ const clean = { ...ev } as Event
+ delete (clean as { relayStatuses?: unknown }).relayStatuses
+ clean.id = key
+
+ return {
+ key,
+ value: clean,
+ addedAt: Date.now(),
+ authorPubkey,
+ targetEventId: targetEventId.toLowerCase()
+ }
+}
diff --git a/src/lib/superchat.test.ts b/src/lib/superchat.test.ts
index ab1917ec..ef20e08c 100644
--- a/src/lib/superchat.test.ts
+++ b/src/lib/superchat.test.ts
@@ -7,6 +7,7 @@ import {
getSuperchatPaytoType,
getSuperchatReferenceFetchId,
isProfileWallPaymentNotification,
+ isProfileWallZapReceipt,
partitionAttestedSuperchats
} from '@/lib/superchat'
import { parsePaytoTagType } from '@/lib/payto'
@@ -118,6 +119,35 @@ describe('partitionAttestedSuperchats', () => {
expect(superchats.map((e) => e.id)).toEqual([payment.id, zapAttested.id])
expect(rest).toEqual([comment])
})
+
+ it('includes attested zaps below the reply threshold at the top', () => {
+ const attested = new Set([ZAP_ID])
+ const microZap = fakeEvent({
+ id: ZAP_ID,
+ kind: kinds.Zap,
+ tags: [
+ ['P', SENDER],
+ ['p', RECIPIENT],
+ ['bolt11', 'lnbc1n1p0fake'],
+ [
+ 'description',
+ JSON.stringify({
+ pubkey: SENDER,
+ content: 'tiny',
+ tags: [['p', RECIPIENT], ['amount', '1000']]
+ })
+ ]
+ ]
+ })
+ const comment = fakeEvent({
+ id: '1'.repeat(64),
+ kind: ExtendedKind.COMMENT,
+ tags: [['e', '2'.repeat(64)]]
+ })
+ const { superchats, rest } = partitionAttestedSuperchats([microZap, comment], attested, 21)
+ expect(superchats.map((e) => e.id)).toEqual([ZAP_ID])
+ expect(rest).toEqual([comment])
+ })
})
describe('getPaymentNotificationInfo', () => {
@@ -226,4 +256,55 @@ describe('profile wall payment notifications', () => {
expect(out).toHaveLength(1)
expect(out[0]!.id).toBe(paymentId)
})
+
+ it('accepts profile-only zap receipt without thread reference', () => {
+ const evt = fakeEvent({
+ kind: kinds.Zap,
+ tags: [
+ ['P', SENDER],
+ ['p', RECIPIENT],
+ ['bolt11', 'lnbc210n1p0fake'],
+ [
+ 'description',
+ JSON.stringify({
+ pubkey: SENDER,
+ content: 'Zap!',
+ tags: [['p', RECIPIENT], ['amount', '21000']]
+ })
+ ]
+ ]
+ })
+ expect(isProfileWallZapReceipt(evt, RECIPIENT)).toBe(true)
+ })
+
+ it('filters to attested profile wall zap receipts', () => {
+ const zap = fakeEvent({
+ id: ZAP_ID,
+ kind: kinds.Zap,
+ tags: [
+ ['P', SENDER],
+ ['p', RECIPIENT],
+ ['bolt11', 'lnbc210n1p0fake'],
+ [
+ 'description',
+ JSON.stringify({
+ pubkey: SENDER,
+ content: 'Wall zap',
+ tags: [['p', RECIPIENT], ['amount', '21000']]
+ })
+ ]
+ ]
+ })
+ const attestation = fakeEvent({
+ kind: ExtendedKind.PAYMENT_ATTESTATION,
+ pubkey: RECIPIENT,
+ tags: [
+ ['e', ZAP_ID],
+ ['k', '9735']
+ ]
+ })
+ const out = filterAttestedProfileWallSuperchats([zap], [attestation], RECIPIENT)
+ expect(out).toHaveLength(1)
+ expect(out[0]!.id).toBe(ZAP_ID)
+ })
})
diff --git a/src/lib/superchat.ts b/src/lib/superchat.ts
index 84a66c2c..217a76c4 100644
--- a/src/lib/superchat.ts
+++ b/src/lib/superchat.ts
@@ -62,6 +62,24 @@ export function buildAttestedPaymentIdSet(
return out
}
+/** Kind 9741 attestation from `recipientPubkey` for payment event `targetEventId`, if any. */
+export function findPaymentAttestationForTarget(
+ attestations: Event[],
+ targetEventId: string,
+ recipientPubkey: string
+): Event | undefined {
+ const target = targetEventId.trim().toLowerCase()
+ const recipient = recipientPubkey.trim().toLowerCase()
+ for (const attestation of attestations) {
+ if (attestation.pubkey.toLowerCase() !== recipient) continue
+ const attestedId = getPaymentAttestationTargetId(attestation)
+ const targetKind = getPaymentAttestationTargetKind(attestation)
+ if (!attestedId || !targetKind) continue
+ if (attestedId.toLowerCase() === target) return attestation
+ }
+ return undefined
+}
+
export function getPaymentNotificationInfo(event: Event): PaymentNotificationInfo | null {
if (event.kind !== ExtendedKind.PAYMENT_NOTIFICATION) return null
@@ -170,18 +188,14 @@ export function sortSuperchatsByAmountDesc(events: Event[]): Event[] {
export function partitionAttestedSuperchats(
items: Event[],
attestedIds: Set,
- zapReplyThreshold: number
+ _zapReplyThreshold: number
): { superchats: Event[]; rest: Event[] } {
const superchats: Event[] = []
const rest: Event[] = []
for (const e of items) {
- if (e.kind === kinds.Zap) {
- if (
- isAttestedSuperchat(e, attestedIds) &&
- getZapInfoFromEvent(e) &&
- getSuperchatAmountSats(e) >= zapReplyThreshold
- ) {
+ if (e.kind === kinds.Zap || e.kind === ExtendedKind.ZAP_RECEIPT) {
+ if (isAttestedSuperchat(e, attestedIds) && getZapInfoFromEvent(e)) {
superchats.push(e)
}
continue
@@ -202,27 +216,23 @@ export function replyFeedSuperchatsFirst(sortedNonSuperchatReplies: Event[], sup
return [...superchats, ...sortedNonSuperchatReplies]
}
-/** Kind 9740 on a profile wall: `p` is the profile owner and there is no note/thread reference. */
-export function isProfileWallPaymentNotification(
- event: Event,
+function isProfileWallThreadReference(
+ referencedEventId: string | undefined,
+ referencedCoordinate: string | undefined,
profilePubkey: string,
profileEventId?: string
): boolean {
- if (event.kind !== ExtendedKind.PAYMENT_NOTIFICATION) return false
- const info = getPaymentNotificationInfo(event)
- if (!info || info.recipientPubkey.toLowerCase() !== profilePubkey.toLowerCase()) return false
-
- if (info.referencedEventId) {
+ if (referencedEventId) {
const profileId = profileEventId?.trim().toLowerCase()
- if (profileId && info.referencedEventId === profileId) return true
+ if (profileId && referencedEventId.toLowerCase() === profileId) return true
return false
}
- if (info.referencedCoordinate) {
+ if (referencedCoordinate) {
const profileCoord = normalizeReplaceableCoordinateString(
getReplaceableCoordinate(kinds.Metadata, profilePubkey, '')
)
- if (normalizeReplaceableCoordinateString(info.referencedCoordinate) === profileCoord) {
+ if (normalizeReplaceableCoordinateString(referencedCoordinate) === profileCoord) {
return true
}
return false
@@ -231,18 +241,62 @@ export function isProfileWallPaymentNotification(
return true
}
+/** Kind 9740 on a profile wall: `p` is the profile owner and there is no note/thread reference. */
+export function isProfileWallPaymentNotification(
+ event: Event,
+ profilePubkey: string,
+ profileEventId?: string
+): boolean {
+ if (event.kind !== ExtendedKind.PAYMENT_NOTIFICATION) return false
+ const info = getPaymentNotificationInfo(event)
+ if (!info || !hexPubkeysEqual(info.recipientPubkey, profilePubkey)) return false
+
+ return isProfileWallThreadReference(
+ info.referencedEventId,
+ info.referencedCoordinate,
+ profilePubkey,
+ profileEventId
+ )
+}
+
+/** Kind 9735 profile zap on a wall: `p` is the profile owner and there is no note/thread reference. */
+export function isProfileWallZapReceipt(
+ event: Event,
+ profilePubkey: string,
+ profileEventId?: string
+): boolean {
+ if (event.kind !== kinds.Zap && event.kind !== ExtendedKind.ZAP_RECEIPT) return false
+ const zapInfo = getZapInfoFromEvent(event)
+ if (!zapInfo?.recipientPubkey || !hexPubkeysEqual(zapInfo.recipientPubkey, profilePubkey)) {
+ return false
+ }
+
+ const referencedEventId = zapInfo.originalEventId?.trim().toLowerCase()
+ return isProfileWallThreadReference(referencedEventId, undefined, profilePubkey, profileEventId)
+}
+
export function filterAttestedProfileWallSuperchats(
- paymentNotifications: Event[],
+ paymentEvents: Event[],
attestations: Event[],
profilePubkey: string,
profileEventId?: string
): Event[] {
const attestedIds = buildAttestedPaymentIdSet(attestations, profilePubkey)
return sortSuperchatsByAmountDesc(
- paymentNotifications.filter(
- (e) =>
- isProfileWallPaymentNotification(e, profilePubkey, profileEventId) &&
- isAttestedSuperchat(e, attestedIds)
- )
+ paymentEvents.filter((e) => {
+ if (e.kind === ExtendedKind.PAYMENT_NOTIFICATION) {
+ return (
+ isProfileWallPaymentNotification(e, profilePubkey, profileEventId) &&
+ isAttestedSuperchat(e, attestedIds)
+ )
+ }
+ if (e.kind === kinds.Zap || e.kind === ExtendedKind.ZAP_RECEIPT) {
+ return (
+ isProfileWallZapReceipt(e, profilePubkey, profileEventId) &&
+ attestedIds.has(e.id.toLowerCase())
+ )
+ }
+ return false
+ })
)
}
diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx
index 3ff2697b..e0900d9f 100644
--- a/src/providers/NostrProvider/index.tsx
+++ b/src/providers/NostrProvider/index.tsx
@@ -1653,6 +1653,20 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
logger.warn('[Publish] Calendar RSVP IndexedDB persist failed', { err })
}
}
+ if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) {
+ try {
+ await indexedDb.putPaymentNotificationRow(event)
+ } catch (err) {
+ logger.warn('[Publish] Payment notification IndexedDB persist failed', { err })
+ }
+ }
+ if (event.kind === ExtendedKind.PAYMENT_ATTESTATION) {
+ try {
+ await indexedDb.putPaymentAttestationRow(event)
+ } catch (err) {
+ logger.warn('[Publish] Payment attestation IndexedDB persist failed', { err })
+ }
+ }
client.emitNewEvent(event)
// Replaceable list events (pins, cache relays, …) must hit IndexedDB + DataLoader, not only RAM
void replaceableEventService.updateReplaceableEventCache(event).catch(() => {})
diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts
index dd24b33b..f2c65d39 100644
--- a/src/services/client-events.service.ts
+++ b/src/services/client-events.service.ts
@@ -730,6 +730,26 @@ export class EventService {
})
})
}
+ if (cleanEvent.kind === ExtendedKind.PAYMENT_NOTIFICATION) {
+ void indexedDb.putPaymentNotificationRow(cleanEvent as NEvent).catch((error: unknown) => {
+ const err = error instanceof Error ? error : new Error(String(error))
+ logger.debug('[EventService] Payment notification IndexedDB persist failed', {
+ kind: cleanEvent.kind,
+ eventId: id,
+ errorMessage: err.message
+ })
+ })
+ }
+ if (cleanEvent.kind === ExtendedKind.PAYMENT_ATTESTATION) {
+ void indexedDb.putPaymentAttestationRow(cleanEvent as NEvent).catch((error: unknown) => {
+ const err = error instanceof Error ? error : new Error(String(error))
+ logger.debug('[EventService] Payment attestation IndexedDB persist failed', {
+ kind: cleanEvent.kind,
+ eventId: id,
+ errorMessage: err.message
+ })
+ })
+ }
}
/** Apply {@link StorageKey.SESSION_EVENT_LRU_MAX} without reload (copies entries into a new LRU). */
diff --git a/src/services/client.service.ts b/src/services/client.service.ts
index 9251ee14..9a22ca07 100644
--- a/src/services/client.service.ts
+++ b/src/services/client.service.ts
@@ -138,6 +138,7 @@ import { collectNip05ValuesFromKind0 } from '@/lib/profile-metadata-search'
import { decodeProfileSearchQueryToPubkeyHex } from '@/lib/profile-search-query'
import { getPubkeysFromPTags, tagNameEquals } from '@/lib/tag'
import { filterRelaysForEventPublish } from '@/lib/relay-publish-filter'
+import { getPaymentAttestationTargetId } from '@/lib/superchat'
import {
buildPublicMessagePublishRelayUrls,
collectRecipientInboxUrls,
@@ -1257,6 +1258,48 @@ class ClientService extends EventTarget {
return pubRelays
}
+ // Payment attestations (9741): attester outbox + attester read inboxes (profile wall REQ) +
+ // payment sender inboxes + relays that carried the attested payment.
+ if (event.kind === ExtendedKind.PAYMENT_ATTESTATION) {
+ const targetEventId = getPaymentAttestationTargetId(event)
+ const paymentSenderPubkey = event.tags.find(([name]) => name === 'e')?.[3]?.trim()
+ const senderPubkeys =
+ paymentSenderPubkey && isValidPubkey(paymentSenderPubkey) ? [paymentSenderPubkey] : []
+ const [authorRelayList, senderRelayLists] = await Promise.all([
+ this.fetchRelayListWithPublishTimeout(event.pubkey),
+ senderPubkeys.length > 0
+ ? this.fetchRelayListsWithPublishTimeout(senderPubkeys)
+ : Promise.resolve([] as TRelayList[])
+ ])
+ const authorWrite = collectSenderOutboxUrls(authorRelayList)
+ const authorRead = collectRecipientInboxUrls(authorRelayList)
+ const senderInboxes = dedupeNormalizeRelayUrlsOrdered(
+ senderRelayLists.flatMap((rl) => collectRecipientInboxUrls(rl))
+ )
+ const seenRelays = targetEventId ? this.getSeenEventRelayUrls(targetEventId) : []
+ const attestationRelays = this.filterPublishingRelays(
+ buildPrioritizedWriteRelayUrls({
+ userWriteRelays: authorWrite,
+ authorReadRelays: dedupeNormalizeRelayUrlsOrdered([...authorRead, ...senderInboxes]),
+ favoriteRelays: favoriteRelayUrls ?? [],
+ extraRelays: seenRelays,
+ maxRelays: MAX_PUBLISH_RELAYS,
+ includeGlobalFastWriteReadTails: useGlobalRelayDefaults,
+ ...writeRelayPubOpts
+ }),
+ event
+ )
+ logger.debug('[DetermineTargetRelays] Payment attestation: outbox + inboxes + seen relays', {
+ kind: event.kind,
+ relayCount: attestationRelays.length,
+ authorWriteCount: authorWrite.length,
+ authorReadCount: authorRead.length,
+ senderInboxCount: senderInboxes.length,
+ seenRelayCount: seenRelays.length
+ })
+ return attestationRelays
+ }
+
let relays: string[]
if (specifiedRelayUrls?.length) {
relays = specifiedRelayUrls
@@ -2224,18 +2267,22 @@ class ClientService extends EventTarget {
add(this.eventService.getSessionEventsMatchingFilters(filters, maxMatches))
- const [timelineRows, archiveRows, publicationRows] = await Promise.all([
+ const [timelineRows, archiveRows, publicationRows, paymentSuperchatRows] = await Promise.all([
this.getTimelineDiskSnapshotEvents(subRequests).catch(() => [] as NEvent[]),
indexedDb
.scanEventArchiveByFilters(filters, { maxRowsScanned, maxMatches })
.catch(() => [] as NEvent[]),
indexedDb
.scanPublicationEventsByFilters(filters, { maxRowsScanned: Math.min(maxRowsScanned, 16_000), maxMatches })
+ .catch(() => [] as NEvent[]),
+ indexedDb
+ .getPaymentSuperchatEventsMatchingFilters(filters, maxMatches)
.catch(() => [] as NEvent[])
])
add(timelineRows)
add(archiveRows)
add(publicationRows)
+ add(paymentSuperchatRows)
return [...byId.values()]
.sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id))
diff --git a/src/services/event-archive.service.ts b/src/services/event-archive.service.ts
index e1347d14..eb1d1875 100644
--- a/src/services/event-archive.service.ts
+++ b/src/services/event-archive.service.ts
@@ -48,6 +48,12 @@ function archiveTierForEvent(ev: Event): number {
function shouldSkipArchiving(ev: Event): boolean {
if (shouldDropEventOnIngest(ev)) return true
if (isNip52CalendarCardKind(ev.kind) || ev.kind === ExtendedKind.CALENDAR_EVENT_RSVP) return true
+ if (
+ ev.kind === ExtendedKind.PAYMENT_NOTIFICATION ||
+ ev.kind === ExtendedKind.PAYMENT_ATTESTATION
+ ) {
+ return true
+ }
if (isReplaceableEvent(ev.kind) && indexedDb.hasReplaceableEventStoreForKind(ev.kind)) {
return true
}
diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts
index a5adf419..efed79b1 100644
--- a/src/services/indexed-db.service.ts
+++ b/src/services/indexed-db.service.ts
@@ -28,6 +28,12 @@ import { profileKind0MatchesSearchQuery } from '@/lib/profile-metadata-search'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { eventMatchesNip50LocalFullTextQuery } from '@/lib/nip50-local-text-match'
import { eventMatchesAnyLocalFeedFilter } from '@/lib/feed-local-event-match'
+import {
+ paymentAttestationIdbRowFromEvent,
+ paymentNotificationIdbRowFromEvent,
+ type PaymentAttestationIdbRow,
+ type PaymentNotificationIdbRow
+} from '@/lib/payment-superchat-idb'
import type { Filter } from 'nostr-tools'
/** Hot archive row in {@link StoreNames.EVENT_ARCHIVE}. */
@@ -138,7 +144,11 @@ export const StoreNames = {
/** NIP-52 calendar notes (31922/31923). Key: {@link replaceableEventDedupeKey}. Index: `occurrenceStartMs`. */
CALENDAR_EVENTS: 'calendarEvents',
/** NIP-52 calendar RSVPs (31925). Key: event id. Index: `parentCoordinate` (`a` tag). */
- CALENDAR_RSVP_EVENTS: 'calendarRsvpEvents'
+ CALENDAR_RSVP_EVENTS: 'calendarRsvpEvents',
+ /** Kind 9740 payment notifications. Key: event id. Indexes: recipient, referenced event/coordinate. */
+ PAYMENT_NOTIFICATION_EVENTS: 'paymentNotificationEvents',
+ /** Kind 9741 payment attestations. Key: event id. Indexes: author (attester), target payment id. */
+ PAYMENT_ATTESTATION_EVENTS: 'paymentAttestationEvents'
}
/** Row shape for {@link StoreNames.CALENDAR_EVENTS}. */
@@ -173,7 +183,9 @@ const CACHE_BROWSER_EVENT_SEARCH_EXCLUDED_STORES: ReadonlySet = new Set(
StoreNames.MUTE_DECRYPTED_TAGS,
StoreNames.FAVORITE_RELAYS,
StoreNames.CALENDAR_EVENTS,
- StoreNames.CALENDAR_RSVP_EVENTS
+ StoreNames.CALENDAR_RSVP_EVENTS,
+ StoreNames.PAYMENT_NOTIFICATION_EVENTS,
+ StoreNames.PAYMENT_ATTESTATION_EVENTS
])
/**
@@ -214,7 +226,7 @@ const FULL_TEXT_NOTE_SEARCH_STORES: ReadonlySet = new Set([
const ARCHIVE_CALENDAR_PURGE_SETTING_KEY = 'archiveCalendarPurgedV37'
/** Schema version we expect. When adding stores or migrations, bump this. */
-const DB_VERSION = 38
+const DB_VERSION = 39
/** Hint age for profile/payment reads (stale rows still returned; background refresh). */
const PROFILE_AND_PAYMENT_STALE_READ_MS = 5 * 60 * 1000
@@ -247,6 +259,15 @@ function ensureMissingObjectStores(db: IDBDatabase): void {
} else if (storeName === StoreNames.CALENDAR_RSVP_EVENTS) {
const rsvp = db.createObjectStore(storeName, { keyPath: 'key' })
rsvp.createIndex('parentCoordinate', 'parentCoordinate', { unique: false })
+ } else if (storeName === StoreNames.PAYMENT_NOTIFICATION_EVENTS) {
+ const pn = db.createObjectStore(storeName, { keyPath: 'key' })
+ pn.createIndex('recipientPubkey', 'recipientPubkey', { unique: false })
+ pn.createIndex('referencedEventId', 'referencedEventId', { unique: false })
+ pn.createIndex('referencedCoordinate', 'referencedCoordinate', { unique: false })
+ } else if (storeName === StoreNames.PAYMENT_ATTESTATION_EVENTS) {
+ const pa = db.createObjectStore(storeName, { keyPath: 'key' })
+ pa.createIndex('authorPubkey', 'authorPubkey', { unique: false })
+ pa.createIndex('targetEventId', 'targetEventId', { unique: false })
} else {
db.createObjectStore(storeName, { keyPath: 'key' })
}
@@ -474,6 +495,19 @@ class IndexedDbService {
if (event.oldVersion < 37) {
// v37: drop legacy object stores; calendar notes purged from EVENT_ARCHIVE post-open
}
+ if (event.oldVersion < 39) {
+ if (!db.objectStoreNames.contains(StoreNames.PAYMENT_NOTIFICATION_EVENTS)) {
+ const pn = db.createObjectStore(StoreNames.PAYMENT_NOTIFICATION_EVENTS, { keyPath: 'key' })
+ pn.createIndex('recipientPubkey', 'recipientPubkey', { unique: false })
+ pn.createIndex('referencedEventId', 'referencedEventId', { unique: false })
+ pn.createIndex('referencedCoordinate', 'referencedCoordinate', { unique: false })
+ }
+ if (!db.objectStoreNames.contains(StoreNames.PAYMENT_ATTESTATION_EVENTS)) {
+ const pa = db.createObjectStore(StoreNames.PAYMENT_ATTESTATION_EVENTS, { keyPath: 'key' })
+ pa.createIndex('authorPubkey', 'authorPubkey', { unique: false })
+ pa.createIndex('targetEventId', 'targetEventId', { unique: false })
+ }
+ }
ensureMissingObjectStores(db)
}
}
@@ -3847,6 +3881,219 @@ class IndexedDbService {
}
})
}
+
+ async putPaymentNotificationRow(ev: Event): Promise {
+ const row = paymentNotificationIdbRowFromEvent(ev)
+ if (!row) return
+ await this.initPromise
+ if (!this.db?.objectStoreNames.contains(StoreNames.PAYMENT_NOTIFICATION_EVENTS)) return
+
+ return new Promise((resolve, reject) => {
+ const tx = this.db!.transaction(StoreNames.PAYMENT_NOTIFICATION_EVENTS, 'readwrite')
+ const store = tx.objectStore(StoreNames.PAYMENT_NOTIFICATION_EVENTS)
+ const getReq = store.get(row.key)
+ getReq.onerror = (e) => reject(idbEventToError(e))
+ getReq.onsuccess = () => {
+ const prev = getReq.result as PaymentNotificationIdbRow | undefined
+ if (prev?.value?.created_at != null && prev.value.created_at > ev.created_at) {
+ resolve()
+ return
+ }
+ const putReq = store.put(row)
+ putReq.onerror = (e) => reject(idbEventToError(e))
+ putReq.onsuccess = () => resolve()
+ }
+ })
+ }
+
+ async putPaymentAttestationRow(ev: Event): Promise {
+ const row = paymentAttestationIdbRowFromEvent(ev)
+ if (!row) return
+ await this.initPromise
+ if (!this.db?.objectStoreNames.contains(StoreNames.PAYMENT_ATTESTATION_EVENTS)) return
+
+ return new Promise((resolve, reject) => {
+ const tx = this.db!.transaction(StoreNames.PAYMENT_ATTESTATION_EVENTS, 'readwrite')
+ const store = tx.objectStore(StoreNames.PAYMENT_ATTESTATION_EVENTS)
+ const getReq = store.get(row.key)
+ getReq.onerror = (e) => reject(idbEventToError(e))
+ getReq.onsuccess = () => {
+ const prev = getReq.result as PaymentAttestationIdbRow | undefined
+ if (prev?.value?.created_at != null && prev.value.created_at > ev.created_at) {
+ resolve()
+ return
+ }
+ const putReq = store.put(row)
+ putReq.onerror = (e) => reject(idbEventToError(e))
+ putReq.onsuccess = () => resolve()
+ }
+ })
+ }
+
+ private async getIndexedEventsByField(
+ storeName: string,
+ indexName: string,
+ fieldValue: string,
+ limit: number
+ ): Promise {
+ await this.initPromise
+ if (!this.db?.objectStoreNames.contains(storeName)) return []
+ const key = fieldValue.trim().toLowerCase()
+ if (!key) return []
+
+ return new Promise((resolve, reject) => {
+ const tx = this.db!.transaction(storeName, 'readonly')
+ const store = tx.objectStore(storeName)
+ let index: IDBIndex
+ try {
+ index = store.index(indexName)
+ } catch {
+ resolve([])
+ return
+ }
+ const req = index.getAll(IDBKeyRange.only(key))
+ req.onerror = (e) => reject(idbEventToError(e))
+ req.onsuccess = () => {
+ const rows = (req.result as { value: Event }[]) ?? []
+ const events = rows.map((r) => r.value).filter(Boolean)
+ events.sort((a, b) => b.created_at - a.created_at)
+ resolve(events.slice(0, limit))
+ }
+ })
+ }
+
+ async getPaymentNotificationsForRecipient(recipientPubkey: string, limit = 200): Promise {
+ return this.getIndexedEventsByField(
+ StoreNames.PAYMENT_NOTIFICATION_EVENTS,
+ 'recipientPubkey',
+ recipientPubkey,
+ limit
+ )
+ }
+
+ async getPaymentNotificationsForReferencedEvent(eventId: string, limit = 200): Promise {
+ return this.getIndexedEventsByField(
+ StoreNames.PAYMENT_NOTIFICATION_EVENTS,
+ 'referencedEventId',
+ eventId,
+ limit
+ )
+ }
+
+ async getPaymentNotificationsForReferencedCoordinate(coordinate: string, limit = 200): Promise {
+ const norm = normalizeReplaceableCoordinateString(coordinate.trim())
+ if (!norm) return []
+ return this.getIndexedEventsByField(
+ StoreNames.PAYMENT_NOTIFICATION_EVENTS,
+ 'referencedCoordinate',
+ norm,
+ limit
+ )
+ }
+
+ async getPaymentAttestationsForAuthor(authorPubkey: string, limit = 500): Promise {
+ return this.getIndexedEventsByField(
+ StoreNames.PAYMENT_ATTESTATION_EVENTS,
+ 'authorPubkey',
+ authorPubkey,
+ limit
+ )
+ }
+
+ async getPaymentAttestationsForTargetEvent(targetEventId: string, limit = 20): Promise {
+ return this.getIndexedEventsByField(
+ StoreNames.PAYMENT_ATTESTATION_EVENTS,
+ 'targetEventId',
+ targetEventId,
+ limit
+ )
+ }
+
+ async getPaymentSuperchatEventsMatchingFilters(filters: Filter[], maxMatches: number): Promise {
+ const out: Event[] = []
+ const seen = new Set()
+ const push = (events: Event[]) => {
+ for (const ev of events) {
+ if (shouldDropEventOnIngest(ev)) continue
+ if (seen.has(ev.id)) continue
+ seen.add(ev.id)
+ out.push(ev)
+ }
+ }
+
+ for (const filter of filters) {
+ const kindsList = filter.kinds
+ const want9740 =
+ !kindsList?.length ||
+ kindsList.includes(ExtendedKind.PAYMENT_NOTIFICATION)
+ const want9741 = !kindsList?.length || kindsList.includes(ExtendedKind.PAYMENT_ATTESTATION)
+ const limit = Math.min(filter.limit ?? maxMatches, maxMatches)
+
+ if (want9740) {
+ const pTags = filter['#p']
+ if (Array.isArray(pTags)) {
+ for (const p of pTags) {
+ if (typeof p !== 'string') continue
+ push(await this.getPaymentNotificationsForRecipient(p, limit))
+ }
+ }
+ const eTags = filter['#e']
+ if (Array.isArray(eTags)) {
+ for (const eid of eTags) {
+ if (typeof eid !== 'string') continue
+ push(await this.getPaymentNotificationsForReferencedEvent(eid, limit))
+ }
+ }
+ const aTags = filter['#a']
+ if (Array.isArray(aTags)) {
+ for (const coord of aTags) {
+ if (typeof coord !== 'string') continue
+ push(await this.getPaymentNotificationsForReferencedCoordinate(coord, limit))
+ }
+ }
+ }
+
+ if (want9741 && Array.isArray(filter.authors)) {
+ for (const author of filter.authors) {
+ if (typeof author !== 'string') continue
+ push(await this.getPaymentAttestationsForAuthor(author, limit))
+ }
+ }
+
+ if (Array.isArray(filter.ids)) {
+ await this.initPromise
+ for (const id of filter.ids) {
+ if (typeof id !== 'string' || !/^[0-9a-f]{64}$/i.test(id)) continue
+ const hex = id.toLowerCase()
+ if (this.db?.objectStoreNames.contains(StoreNames.PAYMENT_NOTIFICATION_EVENTS)) {
+ const ev = await new Promise((resolve) => {
+ const tx = this.db!.transaction(StoreNames.PAYMENT_NOTIFICATION_EVENTS, 'readonly')
+ const req = tx.objectStore(StoreNames.PAYMENT_NOTIFICATION_EVENTS).get(hex)
+ req.onsuccess = () => resolve((req.result as PaymentNotificationIdbRow | undefined)?.value)
+ req.onerror = () => resolve(undefined)
+ })
+ if (ev) push([ev])
+ }
+ if (this.db?.objectStoreNames.contains(StoreNames.PAYMENT_ATTESTATION_EVENTS)) {
+ const ev = await new Promise((resolve) => {
+ const tx = this.db!.transaction(StoreNames.PAYMENT_ATTESTATION_EVENTS, 'readonly')
+ const req = tx.objectStore(StoreNames.PAYMENT_ATTESTATION_EVENTS).get(hex)
+ req.onsuccess = () => resolve((req.result as PaymentAttestationIdbRow | undefined)?.value)
+ req.onerror = () => resolve(undefined)
+ })
+ if (ev) push([ev])
+ }
+ }
+ }
+
+ if (out.length >= maxMatches) break
+ }
+
+ return out
+ .filter((ev) => eventMatchesAnyLocalFeedFilter(ev, filters))
+ .sort((a, b) => b.created_at - a.created_at)
+ .slice(0, maxMatches)
+ }
}
const instance = IndexedDbService.getInstance()