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:
parent
38317ee2a5
commit
1a834f7b40
4 changed files with 68 additions and 6 deletions
10
AGENTS.md
10
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue