|
|
|
@ -1,16 +1,101 @@ |
|
|
|
import { ExtendedKind } from '@/constants' |
|
|
|
import { ExtendedKind } from '@/constants' |
|
|
|
import { findPaymentAttestationForTarget } from '@/lib/superchat' |
|
|
|
import { buildAttestedPaymentIdSet, findPaymentAttestationForTarget } from '@/lib/superchat' |
|
|
|
|
|
|
|
import { normalizeHexPubkey } from '@/lib/pubkey' |
|
|
|
import client from '@/services/client.service' |
|
|
|
import client from '@/services/client.service' |
|
|
|
import indexedDb from '@/services/indexed-db.service' |
|
|
|
import indexedDb from '@/services/indexed-db.service' |
|
|
|
import type { Event as NostrEvent, Filter } from 'nostr-tools' |
|
|
|
import type { Event as NostrEvent, Filter } from 'nostr-tools' |
|
|
|
|
|
|
|
|
|
|
|
const attestationByTargetKey = new Map<string, NostrEvent>() |
|
|
|
const attestationByTargetKey = new Map<string, NostrEvent>() |
|
|
|
const relayFetchByTargetKey = new Map<string, Promise<NostrEvent[]>>() |
|
|
|
const relayFetchByTargetKey = new Map<string, Promise<NostrEvent[]>>() |
|
|
|
|
|
|
|
const authorHydrateByPubkey = new Map<string, Promise<void>>() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const LOCAL_ATTESTED_KEY_PREFIX = 'jumble:attested-payment-ids:' |
|
|
|
|
|
|
|
|
|
|
|
export function paymentAttestationCacheKey(targetEventId: string, recipientPubkey: string): string { |
|
|
|
export function paymentAttestationCacheKey(targetEventId: string, recipientPubkey: string): string { |
|
|
|
return `${targetEventId.trim().toLowerCase()}:${recipientPubkey.trim().toLowerCase()}` |
|
|
|
return `${targetEventId.trim().toLowerCase()}:${recipientPubkey.trim().toLowerCase()}` |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
function readLocalAttestedIds(recipientPubkey: string): Set<string> { |
|
|
|
|
|
|
|
const pk = normalizeHexPubkey(recipientPubkey) |
|
|
|
|
|
|
|
if (!/^[0-9a-f]{64}$/.test(pk)) return new Set() |
|
|
|
|
|
|
|
try { |
|
|
|
|
|
|
|
const raw = localStorage.getItem(`${LOCAL_ATTESTED_KEY_PREFIX}${pk}`) |
|
|
|
|
|
|
|
if (!raw) return new Set() |
|
|
|
|
|
|
|
const parsed = JSON.parse(raw) as unknown |
|
|
|
|
|
|
|
if (!Array.isArray(parsed)) return new Set() |
|
|
|
|
|
|
|
return new Set( |
|
|
|
|
|
|
|
parsed |
|
|
|
|
|
|
|
.filter((id): id is string => typeof id === 'string') |
|
|
|
|
|
|
|
.map((id) => id.trim().toLowerCase()) |
|
|
|
|
|
|
|
.filter((id) => /^[0-9a-f]{64}$/.test(id)) |
|
|
|
|
|
|
|
) |
|
|
|
|
|
|
|
} catch { |
|
|
|
|
|
|
|
return new Set() |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** Durable local record that this payment was attested (survives reloads and relay failures). */ |
|
|
|
|
|
|
|
export function markLocalAttestationTarget(recipientPubkey: string, targetEventId: string): void { |
|
|
|
|
|
|
|
const pk = normalizeHexPubkey(recipientPubkey) |
|
|
|
|
|
|
|
const targetId = targetEventId.trim().toLowerCase() |
|
|
|
|
|
|
|
if (!/^[0-9a-f]{64}$/.test(pk) || !/^[0-9a-f]{64}$/.test(targetId)) return |
|
|
|
|
|
|
|
const ids = readLocalAttestedIds(pk) |
|
|
|
|
|
|
|
if (ids.has(targetId)) return |
|
|
|
|
|
|
|
ids.add(targetId) |
|
|
|
|
|
|
|
try { |
|
|
|
|
|
|
|
localStorage.setItem(`${LOCAL_ATTESTED_KEY_PREFIX}${pk}`, JSON.stringify([...ids])) |
|
|
|
|
|
|
|
} catch { |
|
|
|
|
|
|
|
/* quota */ |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
export function isLocallyMarkedAttested(recipientPubkey: string, targetEventId: string): boolean { |
|
|
|
|
|
|
|
return readLocalAttestedIds(recipientPubkey).has(targetEventId.trim().toLowerCase()) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** Synchronous attested payment ids from durable local storage (no network). */ |
|
|
|
|
|
|
|
export function readKnownAttestedPaymentTargetsSync(recipientPubkey: string): Set<string> { |
|
|
|
|
|
|
|
return new Set(readLocalAttestedIds(recipientPubkey)) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** Attested payment target ids from local storage, IndexedDB, session, and optional relay batch. */ |
|
|
|
|
|
|
|
export async function resolveAttestedPaymentIdSet( |
|
|
|
|
|
|
|
recipientPubkey: string, |
|
|
|
|
|
|
|
relayAttestations: NostrEvent[] = [] |
|
|
|
|
|
|
|
): Promise<Set<string>> { |
|
|
|
|
|
|
|
const pk = normalizeHexPubkey(recipientPubkey) |
|
|
|
|
|
|
|
if (!/^[0-9a-f]{64}$/.test(pk)) return new Set() |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const out = new Set(readLocalAttestedIds(pk)) |
|
|
|
|
|
|
|
await hydrateAttestationsForAuthor(pk) |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const attestations: NostrEvent[] = [] |
|
|
|
|
|
|
|
const seen = new Set<string>() |
|
|
|
|
|
|
|
const push = (ev: NostrEvent) => { |
|
|
|
|
|
|
|
if (seen.has(ev.id)) return |
|
|
|
|
|
|
|
seen.add(ev.id) |
|
|
|
|
|
|
|
attestations.push(ev) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
for (const attestation of await indexedDb.getPaymentAttestationsForAuthor(pk, 1000)) { |
|
|
|
|
|
|
|
push(attestation) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
for (const attestation of client.eventService.getSessionEventsMatchingFilters( |
|
|
|
|
|
|
|
[{ kinds: [ExtendedKind.PAYMENT_ATTESTATION], authors: [pk], limit: 500 }], |
|
|
|
|
|
|
|
500 |
|
|
|
|
|
|
|
)) { |
|
|
|
|
|
|
|
push(attestation) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
for (const attestation of relayAttestations) { |
|
|
|
|
|
|
|
push(attestation) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
for (const id of buildAttestedPaymentIdSet(attestations, pk)) { |
|
|
|
|
|
|
|
out.add(id) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
return out |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
export function peekCachedPaymentAttestation( |
|
|
|
export function peekCachedPaymentAttestation( |
|
|
|
targetEventId: string, |
|
|
|
targetEventId: string, |
|
|
|
recipientPubkey: string |
|
|
|
recipientPubkey: string |
|
|
|
@ -27,6 +112,7 @@ export function rememberPaymentAttestation( |
|
|
|
paymentAttestationCacheKey(targetEventId, recipientPubkey), |
|
|
|
paymentAttestationCacheKey(targetEventId, recipientPubkey), |
|
|
|
attestation |
|
|
|
attestation |
|
|
|
) |
|
|
|
) |
|
|
|
|
|
|
|
markLocalAttestationTarget(recipientPubkey, targetEventId) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
export function resolvePaymentAttestationFromEvents( |
|
|
|
export function resolvePaymentAttestationFromEvents( |
|
|
|
@ -41,6 +127,36 @@ export function resolvePaymentAttestationFromEvents( |
|
|
|
return match |
|
|
|
return match |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
/** Load all known attestations by this author into the in-memory cache (once per session). */ |
|
|
|
|
|
|
|
export async function hydrateAttestationsForAuthor(authorPubkey: string): Promise<void> { |
|
|
|
|
|
|
|
const pk = normalizeHexPubkey(authorPubkey) |
|
|
|
|
|
|
|
if (!/^[0-9a-f]{64}$/.test(pk)) return |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
let inflight = authorHydrateByPubkey.get(pk) |
|
|
|
|
|
|
|
if (!inflight) { |
|
|
|
|
|
|
|
inflight = (async () => { |
|
|
|
|
|
|
|
const idbAttestations = await indexedDb.getPaymentAttestationsForAuthor(pk, 1000) |
|
|
|
|
|
|
|
for (const attestation of idbAttestations) { |
|
|
|
|
|
|
|
rememberPaymentAttestationFromPublish(attestation) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
const sessionHits = client.eventService.getSessionEventsMatchingFilters( |
|
|
|
|
|
|
|
[{ kinds: [ExtendedKind.PAYMENT_ATTESTATION], authors: [pk], limit: 500 }], |
|
|
|
|
|
|
|
500 |
|
|
|
|
|
|
|
) |
|
|
|
|
|
|
|
for (const attestation of sessionHits) { |
|
|
|
|
|
|
|
rememberPaymentAttestationFromPublish(attestation) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
})().finally(() => { |
|
|
|
|
|
|
|
if (authorHydrateByPubkey.get(pk) === inflight) { |
|
|
|
|
|
|
|
authorHydrateByPubkey.delete(pk) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
}) |
|
|
|
|
|
|
|
authorHydrateByPubkey.set(pk, inflight) |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
await inflight |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
export async function loadPaymentAttestationLocal( |
|
|
|
export async function loadPaymentAttestationLocal( |
|
|
|
targetEventId: string, |
|
|
|
targetEventId: string, |
|
|
|
recipientPubkey: string, |
|
|
|
recipientPubkey: string, |
|
|
|
@ -49,6 +165,16 @@ export async function loadPaymentAttestationLocal( |
|
|
|
const cached = peekCachedPaymentAttestation(targetEventId, recipientPubkey) |
|
|
|
const cached = peekCachedPaymentAttestation(targetEventId, recipientPubkey) |
|
|
|
if (cached) return cached |
|
|
|
if (cached) return cached |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (isLocallyMarkedAttested(recipientPubkey, targetEventId)) { |
|
|
|
|
|
|
|
await hydrateAttestationsForAuthor(recipientPubkey) |
|
|
|
|
|
|
|
const hydrated = peekCachedPaymentAttestation(targetEventId, recipientPubkey) |
|
|
|
|
|
|
|
if (hydrated) return hydrated |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
await hydrateAttestationsForAuthor(recipientPubkey) |
|
|
|
|
|
|
|
const afterHydrate = peekCachedPaymentAttestation(targetEventId, recipientPubkey) |
|
|
|
|
|
|
|
if (afterHydrate) return afterHydrate |
|
|
|
|
|
|
|
|
|
|
|
const sessionHits = client.eventService.getSessionEventsMatchingFilters([filter], 5) |
|
|
|
const sessionHits = client.eventService.getSessionEventsMatchingFilters([filter], 5) |
|
|
|
const fromSession = resolvePaymentAttestationFromEvents(sessionHits, targetEventId, recipientPubkey) |
|
|
|
const fromSession = resolvePaymentAttestationFromEvents(sessionHits, targetEventId, recipientPubkey) |
|
|
|
if (fromSession) return fromSession |
|
|
|
if (fromSession) return fromSession |
|
|
|
@ -66,6 +192,10 @@ export async function refreshPaymentAttestationFromRelays( |
|
|
|
const cached = peekCachedPaymentAttestation(targetEventId, recipientPubkey) |
|
|
|
const cached = peekCachedPaymentAttestation(targetEventId, recipientPubkey) |
|
|
|
if (cached) return cached |
|
|
|
if (cached) return cached |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if (isLocallyMarkedAttested(recipientPubkey, targetEventId)) { |
|
|
|
|
|
|
|
return undefined |
|
|
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
const key = paymentAttestationCacheKey(targetEventId, recipientPubkey) |
|
|
|
const key = paymentAttestationCacheKey(targetEventId, recipientPubkey) |
|
|
|
let inflight = relayFetchByTargetKey.get(key) |
|
|
|
let inflight = relayFetchByTargetKey.get(key) |
|
|
|
if (!inflight) { |
|
|
|
if (!inflight) { |
|
|
|
@ -89,7 +219,10 @@ export async function refreshPaymentAttestationFromRelays( |
|
|
|
|
|
|
|
|
|
|
|
export function rememberPaymentAttestationFromPublish(attestation: NostrEvent): void { |
|
|
|
export function rememberPaymentAttestationFromPublish(attestation: NostrEvent): void { |
|
|
|
if (attestation.kind !== ExtendedKind.PAYMENT_ATTESTATION) return |
|
|
|
if (attestation.kind !== ExtendedKind.PAYMENT_ATTESTATION) return |
|
|
|
const targetId = attestation.tags.find(([name]) => name === 'e' || name === 'E')?.[1]?.trim().toLowerCase() |
|
|
|
const targetId = attestation.tags |
|
|
|
|
|
|
|
.find(([name]) => name === 'e' || name === 'E')?.[1] |
|
|
|
|
|
|
|
?.trim() |
|
|
|
|
|
|
|
.toLowerCase() |
|
|
|
if (!targetId || !/^[0-9a-f]{64}$/.test(targetId)) return |
|
|
|
if (!targetId || !/^[0-9a-f]{64}$/.test(targetId)) return |
|
|
|
rememberPaymentAttestation(targetId, attestation.pubkey, attestation) |
|
|
|
rememberPaymentAttestation(targetId, attestation.pubkey, attestation) |
|
|
|
} |
|
|
|
} |
|
|
|
|