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
|
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`;
|
tagged ones) and **auto-hidden** spots (the map drops them at `MapPage:847`;
|
||||||
the stats can't know about reports without querying them).
|
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).**
|
- **Reverse geocoding hits Nominatim — disclosed (2026-10-01).**
|
||||||
`src/lib/geocodeRegions.ts` (`reverseGeocodeRegions` / `geocodeBigDataCloud` /
|
`src/lib/geocodeRegions.ts` (`reverseGeocodeRegions` / `geocodeBigDataCloud` /
|
||||||
`pushRegionTags`) is the **single source of truth**, used by **spot create, spot
|
`pushRegionTags`) is the **single source of truth**, used by **spot create, spot
|
||||||
|
|
|
||||||
|
|
@ -98,8 +98,14 @@ export interface GrowthStats {
|
||||||
totalListings: number;
|
totalListings: number;
|
||||||
/** Completed trades (sale/purchase attestations; revoked excluded). */
|
/** Completed trades (sale/purchase attestations; revoked excluded). */
|
||||||
totalTrades: number;
|
totalTrades: number;
|
||||||
/** Sats recorded across those attestations (self-reported). */
|
/** Robust sats: MUTUAL trades only, per-pair capped, junk amounts dropped. */
|
||||||
tradedSats: number;
|
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). */
|
/** Trades by self-reported method (cash / lightning / trade / other). */
|
||||||
tradesByMethod: { method: string; count: number }[];
|
tradesByMethod: { method: string; count: number }[];
|
||||||
|
|
||||||
|
|
@ -278,11 +284,14 @@ export function useGrowthStats() {
|
||||||
const trades = dedup(tradeEvents).filter(
|
const trades = dedup(tradeEvents).filter(
|
||||||
(e) => e.tags.find((t) => t[0] === 's')?.[1] !== 'revoked',
|
(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>();
|
const methodCounts = new Map<string, number>();
|
||||||
for (const t of trades) {
|
for (const t of trades) {
|
||||||
const amount = Number(t.tags.find((x) => x[0] === 'amount')?.[1] ?? 0);
|
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';
|
const method = t.tags.find((x) => x[0] === 'method')?.[1] ?? 'other';
|
||||||
methodCounts.set(method, (methodCounts.get(method) ?? 0) + 1);
|
methodCounts.set(method, (methodCounts.get(method) ?? 0) + 1);
|
||||||
}
|
}
|
||||||
|
|
@ -290,6 +299,40 @@ export function useGrowthStats() {
|
||||||
.map(([method, count]) => ({ method, count }))
|
.map(([method, count]) => ({ method, count }))
|
||||||
.sort((a, b) => b.count - a.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 ──────────────────────────────────────────────────────────
|
// ── Regions ──────────────────────────────────────────────────────────
|
||||||
// Spots AND shops are considered together. A location's region comes from
|
// Spots AND shops are considered together. A location's region comes from
|
||||||
// its own `city`/`country` tag when present; otherwise it is DERIVED from
|
// its own `city`/`country` tag when present; otherwise it is DERIVED from
|
||||||
|
|
@ -512,6 +555,9 @@ export function useGrowthStats() {
|
||||||
totalListings: listings.length,
|
totalListings: listings.length,
|
||||||
totalTrades: trades.length,
|
totalTrades: trades.length,
|
||||||
tradedSats,
|
tradedSats,
|
||||||
|
tradedSatsClaimed,
|
||||||
|
tradedCount,
|
||||||
|
tradedTraders,
|
||||||
tradesByMethod,
|
tradesByMethod,
|
||||||
zapCount,
|
zapCount,
|
||||||
zapSats,
|
zapSats,
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ import type { Query } from '@tanstack/react-query';
|
||||||
const IDB_KEY = 'skatespots:react-query-cache';
|
const IDB_KEY = 'skatespots:react-query-cache';
|
||||||
|
|
||||||
/** Bump when the persisted data shape changes to invalidate old caches. */
|
/** 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). */
|
/** Discard any persisted cache older than this (ms). */
|
||||||
export const PERSIST_MAX_AGE = 24 * 60 * 60 * 1000; // 24h
|
export const PERSIST_MAX_AGE = 24 * 60 * 60 * 1000; // 24h
|
||||||
|
|
|
||||||
|
|
@ -361,8 +361,14 @@ export default function CommunityStatsPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<p className="mt-3 text-[11px] leading-relaxed text-gray-600">
|
<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).
|
<strong className="text-gray-400">{stats?.tradedCount ?? 0}</strong> mutual trade
|
||||||
"Zaps to mappers" counts only receipts addressed to our top contributors — it is
|
{(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.
|
<strong className="text-gray-500"> not </strong> global zap volume.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue