From a745bdbbcf923af043bafd4af35cc5326f44a995 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 2 Oct 2026 23:28:34 +0700 Subject: [PATCH] =?UTF-8?q?fix(trust):=20C5b=20=E2=80=94=20featured=20rank?= =?UTF-8?q?ing=20requires=20a=20verified=20provider=20receipt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A ['featured', expiry] tag is self-asserted, so anyone could mint one and buy top placement (the badge verified it, ordering didn't). - New src/lib/featuredVerification.ts resolves the provider pubkey (cached) and batch-checks provider-authored kind-9735 zap receipts for the featured coords. - useTours / useSkateEvents / useUpcomingSkateEvents / useLabProfiles now rank only receipts-backed featured items first. - Availability fallback: if the provider can't be resolved or the query fails, the self-asserted ordering is kept, so a provider outage never blanks paid placement. --- src/hooks/useLabProfiles.ts | 24 +++++++++++++-- src/hooks/useSkateEvents.ts | 25 +++++++++++---- src/hooks/useTours.ts | 23 ++++++++------ src/lib/featuredVerification.ts | 54 +++++++++++++++++++++++++++++++++ 4 files changed, 108 insertions(+), 18 deletions(-) create mode 100644 src/lib/featuredVerification.ts diff --git a/src/hooks/useLabProfiles.ts b/src/hooks/useLabProfiles.ts index 44e1437..c4c1c50 100644 --- a/src/hooks/useLabProfiles.ts +++ b/src/hooks/useLabProfiles.ts @@ -14,6 +14,7 @@ import { import type { NostrEvent } from '@nostrify/nostrify'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useAdminModeration, filterAdminHiddenMixed } from '@/hooks/useAdminModeration'; +import { verifiedFeaturedCoords } from '@/lib/featuredVerification'; export type LabProfileType = | 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder' @@ -39,6 +40,15 @@ const LAB_SELF_TAGS = [ 'skatebarber', ]; +/** profileType → addressable kind, for building a coordinate. */ +const KIND_BY_TYPE: Record = { + coach: SKATE_COACH_KIND, filmer: SKATE_FILMER_KIND, editor: SKATE_EDITOR_KIND, + photographer: SKATE_PHOTOGRAPHER_KIND, builder: SKATE_BUILDER_KIND, artist: SKATE_ARTIST_KIND, + graffiti: SKATE_GRAFFITI_KIND, tattoo: SKATE_TATTOO_KIND, trainer: SKATE_TRAINER_KIND, + musician: SKATE_MUSICIAN_KIND, judge: SKATE_JUDGE_KIND, organizer: SKATE_ORGANIZER_KIND, + barber: SKATE_BARBER_KIND, +}; + /** Parse any Lab event into a LabProfile, or null if invalid / unknown kind. */ export function parseLabProfile(event: NostrEvent): LabProfile | null { switch (event.kind) { @@ -129,10 +139,18 @@ export function useLabProfiles(limit = 100, options: { includeHidden?: boolean } if (parsed) profiles.push(parsed); } - // Featured (Pro) profiles first, then newest. + // Featured (Pro) profiles first — but only when backed by a provider + // receipt (a self-asserted tag must not buy placement). Fallback to the + // tag when verification is unavailable. + const coordOf = (p: LabProfile) => `${KIND_BY_TYPE[p.profileType]}:${p.pubkey}:${p.identifier}`; + const featuredCoords = profiles.filter((p) => isFeaturedActive(p.featuredUntil)).map(coordOf); + const verified = await verifiedFeaturedCoords(featuredCoords, (f) => querySkateContent([f], { signal })); + const isPro = (p: LabProfile) => + isFeaturedActive(p.featuredUntil) && (verified === null || verified.has(coordOf(p))); + return profiles.sort((a, b) => { - const aPro = isFeaturedActive(a.featuredUntil) ? 1 : 0; - const bPro = isFeaturedActive(b.featuredUntil) ? 1 : 0; + const aPro = isPro(a) ? 1 : 0; + const bPro = isPro(b) ? 1 : 0; if (aPro !== bPro) return bPro - aPro; return b.createdAt - a.createdAt; }); diff --git a/src/hooks/useSkateEvents.ts b/src/hooks/useSkateEvents.ts index 785033e..931ebe1 100644 --- a/src/hooks/useSkateEvents.ts +++ b/src/hooks/useSkateEvents.ts @@ -2,6 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { CALENDAR_EVENT_KIND, parseSkateEvent, isFeaturedActive, type SkateEvent } from '@/lib/types'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration'; +import { verifiedFeaturedCoords } from '@/lib/featuredVerification'; export function useSkateEvents(limit = 50, options: { includeHidden?: boolean } = {}) { const { includeHidden = false } = options; @@ -33,10 +34,16 @@ export function useSkateEvents(limit = 50, options: { includeHidden?: boolean } if (skateEvent) skateEvents.push(skateEvent); } - // Featured (paid) events first, then by soonest start time. + // Verified/paid featured first, then soonest start time. + const coordOf = (ev: SkateEvent) => `${CALENDAR_EVENT_KIND}:${ev.pubkey}:${ev.identifier}`; + const featuredCoords = skateEvents.filter((ev) => isFeaturedActive(ev.featuredUntil)).map(coordOf); + const verified = await verifiedFeaturedCoords(featuredCoords, (f) => querySkateContent([f], { signal })); + const isPro = (ev: SkateEvent) => + isFeaturedActive(ev.featuredUntil) && (verified === null || verified.has(coordOf(ev))); + return skateEvents.sort((a, b) => { - const aF = isFeaturedActive(a.featuredUntil) ? 1 : 0; - const bF = isFeaturedActive(b.featuredUntil) ? 1 : 0; + const aF = isPro(a) ? 1 : 0; + const bF = isPro(b) ? 1 : 0; if (aF !== bF) return bF - aF; return a.start - b.start; }); @@ -76,11 +83,17 @@ export function useUpcomingSkateEvents(limit = 20) { } } - // Featured (paid) events first, then soonest start time. + // Verified/paid featured first, then soonest start time. + const coordOf = (ev: SkateEvent) => `${CALENDAR_EVENT_KIND}:${ev.pubkey}:${ev.identifier}`; + const featuredCoords = skateEvents.filter((ev) => isFeaturedActive(ev.featuredUntil)).map(coordOf); + const verified = await verifiedFeaturedCoords(featuredCoords, (f) => querySkateContent([f], { signal })); + const isPro = (ev: SkateEvent) => + isFeaturedActive(ev.featuredUntil) && (verified === null || verified.has(coordOf(ev))); + return skateEvents .sort((a, b) => { - const aF = isFeaturedActive(a.featuredUntil) ? 1 : 0; - const bF = isFeaturedActive(b.featuredUntil) ? 1 : 0; + const aF = isPro(a) ? 1 : 0; + const bF = isPro(b) ? 1 : 0; if (aF !== bF) return bF - aF; return a.start - b.start; }) diff --git a/src/hooks/useTours.ts b/src/hooks/useTours.ts index 52c7a0c..69cbf20 100644 --- a/src/hooks/useTours.ts +++ b/src/hooks/useTours.ts @@ -2,14 +2,7 @@ import { useQuery } from '@tanstack/react-query'; import { SKATE_TOUR_KIND, parseSkateTour, isFeaturedActive, type SkateTour } from '@/lib/types'; import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration'; - -/** Sort featured (Pro) tours first, then newest. */ -function sortTours(a: SkateTour, b: SkateTour): number { - const af = isFeaturedActive(a.featuredUntil) ? 1 : 0; - const bf = isFeaturedActive(b.featuredUntil) ? 1 : 0; - if (af !== bf) return bf - af; - return b.createdAt - a.createdAt; -} +import { verifiedFeaturedCoords } from '@/lib/featuredVerification'; const STALE_TIME = 5 * 60 * 1000; // 5 min const GC_TIME = 30 * 60 * 1000; // 30 min @@ -45,7 +38,19 @@ export function useTours(limit = 100, options: { includeHidden?: boolean } = {}) if (tour) tours.push(tour); } - return tours.sort(sortTours); + // Rank featured (Pro) first — but only when backed by a provider receipt. + const coordOf = (tr: SkateTour) => `${SKATE_TOUR_KIND}:${tr.pubkey}:${tr.identifier}`; + const featuredCoords = tours.filter((tr) => isFeaturedActive(tr.featuredUntil)).map(coordOf); + const verified = await verifiedFeaturedCoords(featuredCoords, (f) => querySkateContent([f], { signal })); + const isPro = (tr: SkateTour) => + isFeaturedActive(tr.featuredUntil) && (verified === null || verified.has(coordOf(tr))); + + return tours.sort((a, b) => { + const af = isPro(a) ? 1 : 0; + const bf = isPro(b) ? 1 : 0; + if (af !== bf) return bf - af; + return b.createdAt - a.createdAt; + }); }, staleTime: STALE_TIME, gcTime: GC_TIME, diff --git a/src/lib/featuredVerification.ts b/src/lib/featuredVerification.ts new file mode 100644 index 0000000..6a75864 --- /dev/null +++ b/src/lib/featuredVerification.ts @@ -0,0 +1,54 @@ +/** + * Featured ("Pro") ranking verification. + * + * SECURITY: a `["featured", ]` tag is SELF-ASSERTED — anyone can add it + * and get top placement, and `useFeaturedVerification` only downgrades the badge, + * never the ordering. This helper resolves which featured coordinates are backed + * by a **provider-authored NIP-57 zap receipt** so list hooks can rank only those. + * + * Availability over strictness: if the provider pubkey can't be resolved or the + * receipt query fails, it returns `null` and the caller keeps the self-asserted + * ordering (so a provider outage never blanks paid placement). + */ +import { PRO_LIGHTNING_ADDRESS } from '@/lib/sasquatchPro'; +import { getZapProviderPubkey } from '@/lib/lightningAddress'; +import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions'; +import type { NostrEvent, NostrFilter } from '@nostrify/nostrify'; + +let providerPromise: Promise | null = null; + +function cachedProvider(): Promise { + if (!providerPromise) { + providerPromise = getZapProviderPubkey(PRO_LIGHTNING_ADDRESS).catch(() => null); + } + return providerPromise; +} + +/** + * @returns the set of featured coords with a verified provider receipt, or + * `null` when verification is unavailable (caller should not demote). + */ +export async function verifiedFeaturedCoords( + coords: string[], + query: (filter: NostrFilter) => Promise, +): Promise | null> { + if (coords.length === 0) return null; + const provider = await cachedProvider(); + if (!provider) return null; + try { + const receipts = await query({ + kinds: [9735], + authors: [provider], + '#a': coords, + '#p': PLATFORM_OWNER_PUBKEYS, + limit: coords.length * 4, + }); + const set = new Set(); + for (const r of receipts) { + for (const [n, v] of r.tags) if (n === 'a' && v) set.add(v); + } + return set; + } catch { + return null; + } +}