From 1a834f7b40961fe1a9f04f30c9143a3c01d1491f Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Thu, 1 Oct 2026 15:53:13 +0700 Subject: [PATCH] fix(stats): robust trade volume instead of a sybil-inflatable sum The marketplace 'Sats Traded' was a naive sum of the self-reported amount tag on 31871, which two colluding accounts could inflate for free. tradedSats is now the robust figure - mutual trades only (a listing attested by >=2 distinct pubkeys), per-pair cap 3, amounts >1 BTC dropped - with tradedCount + tradedTraders, and tradedSatsClaimed keeps the raw claim so the card shows both. Added query fields => PERSIST_BUSTER v5. No trade attestations exist on the relay yet (honest 0). --- AGENTS.md | 10 ++++++ src/hooks/useGrowthStats.ts | 52 ++++++++++++++++++++++++++++++-- src/lib/queryPersister.ts | 2 +- src/pages/CommunityStatsPage.tsx | 10 ++++-- 4 files changed, 68 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index ca2a69a..5713661 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -919,6 +919,16 @@ session with **no other memory of this project**. So: legitimate): **untagged pins** (the map draws them, the region rows only count tagged ones) and **auto-hidden** spots (the map drops them at `MapPage:847`; the stats can't know about reports without querying them). +- **Anti-fake-volume on `/stats` (2026-10-01):** the marketplace "Sats Traded" + used to be a naive sum of the self-reported `amount` tag on `31871`, so two + colluding accounts could inflate it for free. `useGrowthStats` now computes + **`tradedSats` robustly** — **mutual trades only** (a listing attested by ≥2 + distinct pubkeys), per-pair cap **3**, amounts **>1 BTC dropped** as junk — + plus `tradedCount` (mutual trades) + `tradedTraders` (distinct people), and + keeps `tradedSatsClaimed` (the raw claim) so the card shows both. New query + fields ⇒ `PERSIST_BUSTER` → **v5**. **There are no trade attestations on the + relay yet**, so the honest values are 0 today. (Broader anti-sybil plan — + claimed-vs-verified tiers, ring detection, PoW — is a follow-up.) - **Reverse geocoding hits Nominatim — disclosed (2026-10-01).** `src/lib/geocodeRegions.ts` (`reverseGeocodeRegions` / `geocodeBigDataCloud` / `pushRegionTags`) is the **single source of truth**, used by **spot create, spot diff --git a/src/hooks/useGrowthStats.ts b/src/hooks/useGrowthStats.ts index 830aa73..aad1697 100644 --- a/src/hooks/useGrowthStats.ts +++ b/src/hooks/useGrowthStats.ts @@ -98,8 +98,14 @@ export interface GrowthStats { totalListings: number; /** Completed trades (sale/purchase attestations; revoked excluded). */ totalTrades: number; - /** Sats recorded across those attestations (self-reported). */ + /** Robust sats: MUTUAL trades only, per-pair capped, junk amounts dropped. */ tradedSats: number; + /** Raw self-reported sats across ALL attestations (a claim — sybil-inflatable). */ + tradedSatsClaimed: number; + /** Mutual trades counted for the robust figure. */ + tradedCount: number; + /** Distinct people involved in those mutual trades. */ + tradedTraders: number; /** Trades by self-reported method (cash / lightning / trade / other). */ tradesByMethod: { method: string; count: number }[]; @@ -278,11 +284,14 @@ export function useGrowthStats() { const trades = dedup(tradeEvents).filter( (e) => e.tags.find((t) => t[0] === 's')?.[1] !== 'revoked', ); - let tradedSats = 0; + + // Raw self-reported total — what the tags CLAIM. A sybil pair can inflate + // this without bound, so it is shown as "claimed", never the headline. + let tradedSatsClaimed = 0; const methodCounts = new Map(); for (const t of trades) { const amount = Number(t.tags.find((x) => x[0] === 'amount')?.[1] ?? 0); - if (Number.isFinite(amount) && amount > 0) tradedSats += amount; + if (Number.isFinite(amount) && amount > 0) tradedSatsClaimed += amount; const method = t.tags.find((x) => x[0] === 'method')?.[1] ?? 'other'; methodCounts.set(method, (methodCounts.get(method) ?? 0) + 1); } @@ -290,6 +299,40 @@ export function useGrowthStats() { .map(([method, count]) => ({ method, count })) .sort((a, b) => b.count - a.count); + // Robust volume — the figure worth trusting. It counts only MUTUAL trades + // (a listing attested by ≥2 distinct people, the signal a swap/ring would + // have to fake on BOTH sides), drops obviously-junk amounts, and caps how + // many trades any one pair can contribute (mirrors the reputation cap). + const MAX_TRADE_SATS = 100_000_000; // 1 BTC — above this the amount is junk + const PER_PAIR_CAP = 3; + const byListing = new Map(); + for (const t of trades) { + const coord = t.tags.find((x) => x[0] === 'a')?.[1]; + if (!coord) continue; + const list = byListing.get(coord) ?? []; + list.push(t); + byListing.set(coord, list); + } + const pairCounts = new Map(); + const traders = new Set(); + let tradedSats = 0; + let tradedCount = 0; + for (const [, list] of byListing) { + const authors = [...new Set(list.map((e) => e.pubkey))]; + if (authors.length < 2) continue; // one-sided = a claim, not a mutual trade + const amounts = list + .map((e) => Number(e.tags.find((x) => x[0] === 'amount')?.[1] ?? 0)) + .filter((n) => Number.isFinite(n) && n > 0 && n <= MAX_TRADE_SATS); + const pairKey = [...authors].sort().join(':'); + const used = pairCounts.get(pairKey) ?? 0; + if (used >= PER_PAIR_CAP) continue; + pairCounts.set(pairKey, used + 1); + tradedCount += 1; + tradedSats += amounts.length ? amounts[0] : 0; + for (const p of authors) traders.add(p); + } + const tradedTraders = traders.size; + // ── Regions ────────────────────────────────────────────────────────── // Spots AND shops are considered together. A location's region comes from // its own `city`/`country` tag when present; otherwise it is DERIVED from @@ -512,6 +555,9 @@ export function useGrowthStats() { totalListings: listings.length, totalTrades: trades.length, tradedSats, + tradedSatsClaimed, + tradedCount, + tradedTraders, tradesByMethod, zapCount, zapSats, diff --git a/src/lib/queryPersister.ts b/src/lib/queryPersister.ts index 8694724..1bd0ec3 100644 --- a/src/lib/queryPersister.ts +++ b/src/lib/queryPersister.ts @@ -40,7 +40,7 @@ import type { Query } from '@tanstack/react-query'; const IDB_KEY = 'skatespots:react-query-cache'; /** Bump when the persisted data shape changes to invalidate old caches. */ -export const PERSIST_BUSTER = 'v4'; +export const PERSIST_BUSTER = 'v5'; /** Discard any persisted cache older than this (ms). */ export const PERSIST_MAX_AGE = 24 * 60 * 60 * 1000; // 24h diff --git a/src/pages/CommunityStatsPage.tsx b/src/pages/CommunityStatsPage.tsx index 6c6a287..460cd0b 100644 --- a/src/pages/CommunityStatsPage.tsx +++ b/src/pages/CommunityStatsPage.tsx @@ -361,8 +361,14 @@ export default function CommunityStatsPage() { )}

- Sats traded are self-reported in trade records (the app can't observe an off-app payment). - "Zaps to mappers" counts only receipts addressed to our top contributors — it is + {stats?.tradedCount ?? 0} mutual trade + {(stats?.tradedCount ?? 0) === 1 ? '' : 's'} across{' '} + {stats?.tradedTraders ?? 0} people. Sats are + self-reported by the traders — the app can't observe an off-app payment — so the headline + counts only mutual trades with a per-pair cap; + the raw claim is{' '} + {(stats?.tradedSatsClaimed ?? 0).toLocaleString()}{' '} + sats. "Zaps to mappers" counts only receipts addressed to our top contributors — it is not global zap volume.