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