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.
This commit is contained in:
parent
8c5b1636f7
commit
864a384f60
3 changed files with 127 additions and 3 deletions
10
AGENTS.md
10
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
|
- **Low-diversity ring warning (2026-10-01):** beyond the single-partner
|
||||||
warning, `TradeReputationCard` flags `verified >= 5 && verifiedCounterparties
|
warning, `TradeReputationCard` flags `verified >= 5 && verifiedCounterparties
|
||||||
<= 2` — "most confirmations come from very few accounts".
|
<= 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
|
- **Cashu escrow** (marketplace): time-locked **P2PK** (lock to seller + buyer
|
||||||
refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts`
|
refund key + locktime) — see **`ESCROW.md`** and `src/lib/cashuEscrow.ts`
|
||||||
(unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a
|
(unit-tested). Protocol: `cashu_token` on the `31872`; each user advertises a
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { useTradeReputation } from '@/hooks/useTradeAttestations';
|
import { useTradeReputation } from '@/hooks/useTradeAttestations';
|
||||||
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
|
||||||
|
import { useBatchActivity, useTradeRings } from '@/hooks/useTradeIntegrity';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Handshake } from 'lucide-react';
|
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 }) {
|
export function TradeReputationCard({ pubkey, className }: { pubkey: string; className?: string }) {
|
||||||
const { data } = useTradeReputation(pubkey);
|
const { data } = useTradeReputation(pubkey);
|
||||||
const verifiedWith = data?.verifiedWith ?? [];
|
const verifiedWith = data?.verifiedWith ?? [];
|
||||||
|
const partnerPubkeys = verifiedWith.map((v) => v.pubkey);
|
||||||
// Trust-weight each mutually-confirmed trade by the counterparty's web-of-trust
|
// Trust-weight each mutually-confirmed trade by the counterparty's web-of-trust
|
||||||
// score, so a ring of fresh accounts can't inflate reputation.
|
// 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);
|
const total = (data?.sales ?? 0) + (data?.purchases ?? 0);
|
||||||
if (!data || total === 0) return null;
|
if (!data || total === 0) return null;
|
||||||
|
|
@ -33,8 +39,12 @@ export function TradeReputationCard({ pubkey, className }: { pubkey: string; cla
|
||||||
const people = data.verifiedCounterparties;
|
const people = data.verifiedCounterparties;
|
||||||
// Cap each counterparty's contribution at 3 so one partner — or a colluding
|
// 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.
|
// 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(
|
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,
|
0,
|
||||||
);
|
);
|
||||||
const trustLabel =
|
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.
|
for each other can look busy — treat this record with care.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
{rings?.isRing && (
|
||||||
|
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-2 py-1.5 text-xs text-amber-300">
|
||||||
|
🔁 These accounts mostly trade with each other — a closed cluster. Mutual
|
||||||
|
confirmation inside a ring is weak evidence.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{data.verified > 0 && (
|
{data.verified > 0 && (
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-xs text-gray-500">
|
||||||
Trust-weighted <span className="font-bold text-[#ccff00]">{weighted.toFixed(1)}</span>
|
Trust & activity weighted <span className="font-bold text-[#ccff00]">{weighted.toFixed(1)}</span>
|
||||||
{' · '}
|
{' · '}
|
||||||
{trustLabel}
|
{trustLabel}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
||||||
98
src/hooks/useTradeIntegrity.ts
Normal file
98
src/hooks/useTradeIntegrity.ts
Normal file
|
|
@ -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<Map<string, number>> => {
|
||||||
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
|
||||||
|
const counts = new Map<string, number>();
|
||||||
|
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<RingAnalysis> => {
|
||||||
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
|
||||||
|
const partnerSet = new Set(partners);
|
||||||
|
const members = new Set<string>([...(pubkey ? [pubkey] : []), ...partners]);
|
||||||
|
|
||||||
|
const events = onlyVerified(await nostr.query(
|
||||||
|
[{ kinds: [TRADE_ATTESTATION_KIND], authors: partners, limit: partners.length * 50 }],
|
||||||
|
{ signal },
|
||||||
|
));
|
||||||
|
|
||||||
|
const partnerPairs = new Set<string>();
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue