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).
This commit is contained in:
SkateSpots 2026-10-01 15:53:13 +07:00
parent 38317ee2a5
commit 1a834f7b40
4 changed files with 68 additions and 6 deletions

View file

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

View file

@ -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<string, number>();
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<string, typeof trades>();
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<string, number>();
const traders = new Set<string>();
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,

View file

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

View file

@ -361,8 +361,14 @@ export default function CommunityStatsPage() {
</div>
)}
<p className="mt-3 text-[11px] leading-relaxed text-gray-600">
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
<strong className="text-gray-400">{stats?.tradedCount ?? 0}</strong> mutual trade
{(stats?.tradedCount ?? 0) === 1 ? '' : 's'} across{' '}
<strong className="text-gray-400">{stats?.tradedTraders ?? 0}</strong> people. Sats are
self-reported by the traders — the app can't observe an off-app payment — so the headline
counts only <strong className="text-gray-400">mutual</strong> trades with a per-pair cap;
the raw claim is{' '}
<strong className="text-gray-500">{(stats?.tradedSatsClaimed ?? 0).toLocaleString()}</strong>{' '}
sats. "Zaps to mappers" counts only receipts addressed to our top contributors — it is
<strong className="text-gray-500"> not </strong> global zap volume.
</p>
</section>