fix(trust): C5b — featured ranking requires a verified provider receipt

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.
This commit is contained in:
SkateSpots 2026-10-02 23:28:34 +07:00
parent 75c9cc4010
commit a745bdbbcf
4 changed files with 108 additions and 18 deletions

View file

@ -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<LabProfileType, number> = {
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;
});

View file

@ -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;
})

View file

@ -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,

View file

@ -0,0 +1,54 @@
/**
* Featured ("Pro") ranking verification.
*
* SECURITY: a `["featured", <expiry>]` 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<string | null> | null = null;
function cachedProvider(): Promise<string | null> {
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<NostrEvent[]>,
): Promise<Set<string> | 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<string>();
for (const r of receipts) {
for (const [n, v] of r.tags) if (n === 'a' && v) set.add(v);
}
return set;
} catch {
return null;
}
}