From 864a384f603be2a42cfc936c3ebaccfbb66ea244 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Thu, 1 Oct 2026 16:26:30 +0700 Subject: [PATCH] feat(reputation): activity weighting + closed-ring detection useTradeIntegrity.ts adds two anti-sybil levers to TradeReputationCard: - useBatchActivity counts each counterparty's published spots+listings and weights their contribution by min(1, activity/3), so a ring of empty accounts contributes ~0 even when cross-confirmed. - useTradeRings does a 2-hop query of the partners' own attestations and measures closedness (share of partner-partner trades); >=3 partners with >=0.5 closedness flags a closed cluster. PoW (NIP-13) remains, but needs mining at publish. --- AGENTS.md | 10 +++ src/components/TradeReputationCard.tsx | 22 +++++- src/hooks/useTradeIntegrity.ts | 98 ++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 src/hooks/useTradeIntegrity.ts diff --git a/AGENTS.md b/AGENTS.md index 2df28fb..e28f925 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -136,6 +136,16 @@ session with **no other memory of this project**. So: - **Low-diversity ring warning (2026-10-01):** beyond the single-partner warning, `TradeReputationCard` flags `verified >= 5 && verifiedCounterparties <= 2` — "most confirmations come from very few accounts". + - **Anti-sybil depth (2026-10-01):** `src/hooks/useTradeIntegrity.ts` adds two + levers to `TradeReputationCard`. **Activity weighting** — `useBatchActivity` + counts each counterparty's published spots+listings and multiplies their + contribution by `min(1, activity/3)`, so a ring of empty accounts contributes + ~0 even when cross-confirmed. **Ring analysis** — `useTradeRings` does a + **2-hop** query of the partners' own attestations and measures **closedness** + (share of possible partner↔partner trades); `≥3` partners with `≥0.5` + closedness flags a closed cluster. ⚠️ **PoW (NIP-13) is the remaining lever** + but requires **mining at publish** (and a read-side verifier) — a verifier + alone does nothing because no events carry a `nonce` yet. Deferred. - **Cashu escrow** (marketplace): time-locked **P2PK** (lock to seller + buyer refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts` (unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a diff --git a/src/components/TradeReputationCard.tsx b/src/components/TradeReputationCard.tsx index 4b1ffa6..2f7689a 100644 --- a/src/components/TradeReputationCard.tsx +++ b/src/components/TradeReputationCard.tsx @@ -1,6 +1,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { useTradeReputation } from '@/hooks/useTradeAttestations'; import { useBatchTrustScores } from '@/hooks/useBatchTrustScores'; +import { useBatchActivity, useTradeRings } from '@/hooks/useTradeIntegrity'; import { cn } from '@/lib/utils'; import { Handshake } from 'lucide-react'; @@ -23,9 +24,14 @@ function Stat({ label, value, accent }: { label: string; value: number; accent?: export function TradeReputationCard({ pubkey, className }: { pubkey: string; className?: string }) { const { data } = useTradeReputation(pubkey); const verifiedWith = data?.verifiedWith ?? []; + const partnerPubkeys = verifiedWith.map((v) => v.pubkey); // Trust-weight each mutually-confirmed trade by the counterparty's web-of-trust // score, so a ring of fresh accounts can't inflate reputation. - const { data: scores } = useBatchTrustScores(verifiedWith.map((v) => v.pubkey)); + const { data: scores } = useBatchTrustScores(partnerPubkeys); + // Anti-sybil depth: weight by REAL activity (spots/listings), and look for a + // closed partner cluster (accounts that mostly trade with each other). + const { data: activity } = useBatchActivity(partnerPubkeys); + const { data: rings } = useTradeRings(pubkey, partnerPubkeys); const total = (data?.sales ?? 0) + (data?.purchases ?? 0); if (!data || total === 0) return null; @@ -33,8 +39,12 @@ export function TradeReputationCard({ pubkey, className }: { pubkey: string; cla const people = data.verifiedCounterparties; // Cap each counterparty's contribution at 3 so one partner — or a colluding // pair confirming each other over and over — can't inflate the score. + // Activity factor: a fresh account with no published content contributes ~0, + // so a ring of empty accounts can't move the score even if cross-confirmed. + const activityFactor = (pk: string) => Math.min(1, (activity?.get(pk) ?? 0) / 3); const weighted = verifiedWith.reduce( - (sum, v) => sum + Math.min(v.count, 3) * ((scores?.get(v.pubkey) ?? 0) / 100), + (sum, v) => + sum + Math.min(v.count, 3) * ((scores?.get(v.pubkey) ?? 0) / 100) * activityFactor(v.pubkey), 0, ); const trustLabel = @@ -87,9 +97,15 @@ export function TradeReputationCard({ pubkey, className }: { pubkey: string; cla for each other can look busy — treat this record with care.

)} + {rings?.isRing && ( +

+ 🔁 These accounts mostly trade with each other — a closed cluster. Mutual + confirmation inside a ring is weak evidence. +

+ )} {data.verified > 0 && (

- Trust-weighted {weighted.toFixed(1)} + Trust & activity weighted {weighted.toFixed(1)} {' · '} {trustLabel}

diff --git a/src/hooks/useTradeIntegrity.ts b/src/hooks/useTradeIntegrity.ts new file mode 100644 index 0000000..4918bff --- /dev/null +++ b/src/hooks/useTradeIntegrity.ts @@ -0,0 +1,98 @@ +/** + * Anti-sybil integrity signals for marketplace reputation. + * + * - `useBatchActivity` — how much REAL content a set of pubkeys has published + * (spots + listings). A ring of fresh accounts has ~0, so its confirmations + * can be weighted down to ~0 even when the trade graph looks busy. + * - `useTradeRings` — a 2-hop look at the peer group: do a pubkey's trading + * partners also trade **with each other**? A closed cluster is the signature + * of a mutual-vouching ring. + * + * Both never throw; they degrade to neutral values. + */ +import { useQuery } from '@tanstack/react-query'; +import { useNostr } from '@nostrify/react'; +import { SKATE_SPOT_KIND, MARKETPLACE_ITEM_KIND } from '@/lib/types'; +import { TRADE_ATTESTATION_KIND } from '@/hooks/useTradeAttestations'; +import { onlyVerified } from '@/lib/verifyEvents'; + +/** Published content count per author (spots + listings). */ +export function useBatchActivity(pubkeys: string[]) { + const { nostr } = useNostr(); + + return useQuery({ + queryKey: ['batch-activity', [...pubkeys].sort().join(',')], + enabled: pubkeys.length > 0, + staleTime: 30 * 60 * 1000, + queryFn: async (c): Promise> => { + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]); + const counts = new Map(); + pubkeys.forEach((pk) => counts.set(pk, 0)); + try { + const events = onlyVerified(await nostr.query( + [{ kinds: [SKATE_SPOT_KIND, MARKETPLACE_ITEM_KIND], authors: pubkeys, limit: 500 }], + { signal }, + )); + for (const ev of events) counts.set(ev.pubkey, (counts.get(ev.pubkey) ?? 0) + 1); + } catch { + /* leave the zeros */ + } + return counts; + }, + }); +} + +export interface RingAnalysis { + /** Distinct partners considered. */ + partners: number; + /** 0..1 — share of possible partner↔partner trades that exist. */ + closedness: number; + /** A self-contained group: ≥3 partners who all trade with one another. */ + isRing: boolean; +} + +/** + * Is the pubkey's mutual-trade partner set a **closed cluster**? We fetch each + * partner's attestations (one query) and count edges between the partners + * themselves — a full clique means they vouch for each other, not the wider + * scene. + */ +export function useTradeRings(pubkey: string | undefined, partners: string[]) { + const { nostr } = useNostr(); + const partnerKey = [...partners].sort().join(','); + + return useQuery({ + queryKey: ['trade-rings', pubkey, partnerKey], + enabled: !!pubkey && partners.length >= 3, + staleTime: 10 * 60 * 1000, + queryFn: async (c): Promise => { + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]); + const partnerSet = new Set(partners); + const members = new Set([...(pubkey ? [pubkey] : []), ...partners]); + + const events = onlyVerified(await nostr.query( + [{ kinds: [TRADE_ATTESTATION_KIND], authors: partners, limit: partners.length * 50 }], + { signal }, + )); + + const partnerPairs = new Set(); + for (const ev of events) { + const counterparty = ev.tags.find(([n]) => n === 'p')?.[1]; + if (!counterparty || counterparty === ev.pubkey) continue; + if (!members.has(ev.pubkey) || !members.has(counterparty)) continue; + // Only edges BETWEEN partners (the subject's own edges don't count). + if (partnerSet.has(ev.pubkey) && partnerSet.has(counterparty)) { + partnerPairs.add([ev.pubkey, counterparty].sort().join(':')); + } + } + + const possible = (partners.length * (partners.length - 1)) / 2; + const closedness = possible > 0 ? partnerPairs.size / possible : 0; + return { + partners: partners.length, + closedness, + isRing: partners.length >= 3 && closedness >= 0.5, + }; + }, + }); +}