From 2986f8bf998b633edc3bee46f1ffc4f328253216 Mon Sep 17 00:00:00 2001
From: Kevin Codex
Date: Wed, 7 Oct 2026 16:05:13 +0800
Subject: [PATCH 01/13] feat(points): Season 1 points, leaderboard, shadow run
Reputation points for launching and trading, recomputed from scratch from
the chain index every hour (lib/points/score.ts, pure and tested). They
reward what costs real money or reputation to fake:
- creators: outside buyers who bought this season and still hold a day
later (30 each when the buyer's profile is points-eligible, 5 if not;
half again at day 7), 10 per $1 of fees from outside trades, 100 for a
token alive at day 7; a token scores 0 if its creator sold half of what
they bought this season; best 3 tokens per launch day. Any token
counts, only season activity.
- scouts: first 25 outside buyers of a token that reaches 50 outside
holders (100, +50 while holding), buys held a day (5, 20 a day), fees
paid on other people's real tokens (10 per $1, capped per token/day).
- never: launches by themselves, own-token trades, trades under $5,
sniper-window buys, launcher / recipients / transfer-fed wallets,
wallets kept off points.
Eligibility (leaderboard + the 6x holder weight) = X-verified profile on
an account POINTS_MIN_X_AGE_DAYS (30) old with POINTS_MIN_X_FOLLOWERS
(20). Unverified wallets see the points waiting for them on /me.
An admin starts a season from /admin (not public: the shadow run), sees
the top 50 of each board eligible or not with why lines, publishes when
the numbers look right, recomputes or ends it. /leaderboard shows a
teaser until then. The rules are on /rules#points with the "no cash
value, not a token" line. One machine computes (session advisory lock);
writes go through the pool in one replace transaction.
---
app/db/schema.sql | 39 ++
app/src/app/admin/page.tsx | 4 +-
app/src/app/api/points/admin/route.ts | 53 +++
app/src/app/api/points/board/route.ts | 19 +
app/src/app/api/points/route.ts | 20 ++
app/src/app/leaderboard/page.tsx | 89 +++++
app/src/app/rules/page.tsx | 19 +-
app/src/app/u/[username]/page.tsx | 13 +
app/src/components/launchpad/MeDashboard.tsx | 2 +
app/src/components/points/PointsAdmin.tsx | 115 ++++++
app/src/components/points/SeasonCard.tsx | 39 ++
app/src/components/points/YourStanding.tsx | 25 ++
app/src/components/points/usePoints.ts | 27 ++
.../components/sections/rules-guide.test.ts | 5 +-
app/src/lib/launchpad/loop.ts | 3 +
app/src/lib/launchpad/queries.ts | 12 +
app/src/lib/points/auth.ts | 10 +
app/src/lib/points/score.test.ts | 160 +++++++++
app/src/lib/points/score.ts | 337 ++++++++++++++++++
app/src/lib/points/server.ts | 272 ++++++++++++++
app/src/lib/profiles/server.ts | 11 +-
21 files changed, 1268 insertions(+), 6 deletions(-)
create mode 100644 app/src/app/api/points/admin/route.ts
create mode 100644 app/src/app/api/points/board/route.ts
create mode 100644 app/src/app/api/points/route.ts
create mode 100644 app/src/app/leaderboard/page.tsx
create mode 100644 app/src/components/points/PointsAdmin.tsx
create mode 100644 app/src/components/points/SeasonCard.tsx
create mode 100644 app/src/components/points/YourStanding.tsx
create mode 100644 app/src/components/points/usePoints.ts
create mode 100644 app/src/lib/points/auth.ts
create mode 100644 app/src/lib/points/score.test.ts
create mode 100644 app/src/lib/points/score.ts
create mode 100644 app/src/lib/points/server.ts
diff --git a/app/db/schema.sql b/app/db/schema.sql
index a48d56ee..cafb51ad 100644
--- a/app/db/schema.sql
+++ b/app/db/schema.sql
@@ -393,3 +393,42 @@ ALTER TABLE bb_launch_swaps ADD COLUMN IF NOT EXISTS trader_via text;
ALTER TABLE bb_launch_swaps ADD COLUMN IF NOT EXISTS tx_from text;
-- its indexes (the unchecked set, receipt-pending calls, one wallet's trades) are in db/concurrent-indexes.sql: built
-- concurrently after this transaction, so no query on swaps waits for them
+
+-- ── points, Season 1 (2026-10-07) ──────────────────────────────────────────
+-- Reputation points for launching and trading, recomputed from scratch every hour from the chain index
+-- (lib/points/score.ts). No cash value, not a token. A season is started, published and ended by an admin; until it
+-- is public the boards are visible to admins only (the shadow run that tunes the rules on real data).
+CREATE TABLE IF NOT EXISTS bb_seasons (
+ id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
+ slug text NOT NULL, -- s1, s2…
+ name text NOT NULL,
+ starts_at timestamptz NOT NULL,
+ ends_at timestamptz NOT NULL,
+ public boolean NOT NULL DEFAULT false,
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE UNIQUE INDEX IF NOT EXISTS bb_seasons_slug_uq ON bb_seasons (slug);
+-- One row per wallet with points in a season (eligible or not: an unverified wallet sees what it would unlock).
+CREATE TABLE IF NOT EXISTS bb_points (
+ season_id integer NOT NULL,
+ wallet text NOT NULL,
+ creator integer NOT NULL DEFAULT 0,
+ scout integer NOT NULL DEFAULT 0,
+ total integer NOT NULL DEFAULT 0,
+ eligible boolean NOT NULL DEFAULT false,
+ rank_creator integer, -- among eligible wallets only
+ rank_scout integer,
+ breakdown jsonb NOT NULL DEFAULT '{}'::jsonb,
+ computed_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (season_id, wallet)
+);
+CREATE INDEX IF NOT EXISTS bb_points_creator_idx ON bb_points (season_id, rank_creator) WHERE rank_creator IS NOT NULL;
+CREATE INDEX IF NOT EXISTS bb_points_scout_idx ON bb_points (season_id, rank_scout) WHERE rank_scout IS NOT NULL;
+-- The most outside holders a token has had this season ("reached 50" survives a later drop).
+CREATE TABLE IF NOT EXISTS bb_points_token_state (
+ season_id integer NOT NULL,
+ chain_id integer NOT NULL,
+ token text NOT NULL,
+ max_outside_holders integer NOT NULL DEFAULT 0,
+ PRIMARY KEY (season_id, chain_id, token)
+);
diff --git a/app/src/app/admin/page.tsx b/app/src/app/admin/page.tsx
index 9de5d3b3..20f50eab 100644
--- a/app/src/app/admin/page.tsx
+++ b/app/src/app/admin/page.tsx
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import AdminQueue from "@/components/launchpad/AdminQueue";
import ProfileQueue from "@/components/profile/ProfileQueue";
+import PointsAdmin from "@/components/points/PointsAdmin";
export const metadata: Metadata = { title: "Moderation", robots: { index: false, follow: false } };
export const dynamic = "force-dynamic";
@@ -11,10 +12,11 @@ export default function AdminPage() {
Moderation
-
Reported posts and profiles. Every action is a signature from an admin wallet.
+
Reported posts, profiles and season points. Every action is a signature from an admin wallet.
+
);
}
diff --git a/app/src/app/api/points/admin/route.ts b/app/src/app/api/points/admin/route.ts
new file mode 100644
index 00000000..b508563d
--- /dev/null
+++ b/app/src/app/api/points/admin/route.ts
@@ -0,0 +1,53 @@
+import { NextResponse } from "next/server";
+import { rateLimited } from "@/lib/launchpad/editServer";
+import { clientIp, readJson } from "@/lib/profiles/http";
+import { admitAdmin } from "@/lib/profiles/server";
+import { buildPointsAdminMessage, isPointsAdminAction } from "@/lib/points/auth";
+import { computePointsIfDue, currentSeason, endSeasonNow, previewBoards, setSeasonPublic, startSeason } from "@/lib/points/server";
+
+export const dynamic = "force-dynamic";
+
+/**
+ * POST {action, days?, chain, wallet, nonce, ts, signature} → one admin-signed points action:
+ * preview (the shadow boards), start (a new season, not public), publish / unpublish, end (now), recompute (now).
+ */
+export async function POST(req: Request) {
+ if (rateLimited(`points-admin:ip:${clientIp(req)}`, 30)) return NextResponse.json({ error: "slow down" }, { status: 429 });
+ const b = await readJson(req);
+ if (!b) return NextResponse.json({ error: "bad json" }, { status: 400 });
+ const action = b.action;
+ if (!isPointsAdminAction(action)) return NextResponse.json({ error: "bad action" }, { status: 400 });
+ const a = await admitAdmin({ chain: b.chain, wallet: b.wallet, nonce: b.nonce, ts: b.ts, signature: b.signature }, (wallet, nonce, ts) => buildPointsAdminMessage({ action, wallet, nonce, ts }));
+ if (!a.ok) return NextResponse.json({ error: a.error }, { status: a.status });
+ try {
+ const season = await currentSeason();
+ switch (action) {
+ case "start": {
+ if (season && new Date(season.ends_at).getTime() > Date.now()) return NextResponse.json({ error: `${season.name} is still running` }, { status: 409 });
+ const days = Math.min(90, Math.max(1, Math.trunc(Number(b.days) || 28)));
+ const s = await startSeason(days);
+ void computePointsIfDue(true).catch(() => {});
+ return NextResponse.json({ ok: true, season: s });
+ }
+ case "publish":
+ case "unpublish":
+ if (!season) return NextResponse.json({ error: "no season" }, { status: 404 });
+ await setSeasonPublic(season.id, action === "publish");
+ return NextResponse.json({ ok: true });
+ case "end":
+ if (!season) return NextResponse.json({ error: "no season" }, { status: 404 });
+ await endSeasonNow(season.id);
+ return NextResponse.json({ ok: true });
+ case "recompute": {
+ const r = await computePointsIfDue(true);
+ return NextResponse.json({ ok: true, result: r });
+ }
+ case "preview":
+ if (!season) return NextResponse.json({ season: null });
+ return NextResponse.json({ season, ...(await previewBoards(season.id)) }, { headers: { "cache-control": "no-store" } });
+ }
+ } catch (err) {
+ console.error("[points] admin failed:", err instanceof Error ? err.message : err);
+ return NextResponse.json({ error: "could not apply" }, { status: 502 });
+ }
+}
diff --git a/app/src/app/api/points/board/route.ts b/app/src/app/api/points/board/route.ts
new file mode 100644
index 00000000..899c5875
--- /dev/null
+++ b/app/src/app/api/points/board/route.ts
@@ -0,0 +1,19 @@
+import { NextResponse } from "next/server";
+import { memo } from "@/lib/launchpad/memo";
+import { boardRows, currentSeason } from "@/lib/points/server";
+
+export const dynamic = "force-dynamic";
+
+/** GET /api/points/board?board=creator|scout → the public leaderboard (404 while the season is not public). */
+export async function GET(req: Request) {
+ const board = new URL(req.url).searchParams.get("board") === "scout" ? "scout" : "creator";
+ try {
+ const season = await memo("points:season", 10_000, currentSeason);
+ if (!season?.public) return NextResponse.json({ error: "no public season" }, { status: 404 });
+ const data = await memo(`points:board:${season.id}:${board}`, 30_000, () => boardRows(season.id, board));
+ return NextResponse.json({ season: { name: season.name, ends_at: season.ends_at }, board, ...data }, { headers: { "cache-control": "no-store" } });
+ } catch (err) {
+ console.error("[points] board failed:", err instanceof Error ? err.message : err);
+ return NextResponse.json({ error: "could not load the board" }, { status: 502 });
+ }
+}
diff --git a/app/src/app/api/points/route.ts b/app/src/app/api/points/route.ts
new file mode 100644
index 00000000..8669258d
--- /dev/null
+++ b/app/src/app/api/points/route.ts
@@ -0,0 +1,20 @@
+import { NextResponse } from "next/server";
+import { isAddress } from "viem";
+import { memo } from "@/lib/launchpad/memo";
+import { currentSeason, walletPoints } from "@/lib/points/server";
+
+export const dynamic = "force-dynamic";
+
+/** GET /api/points[?wallet=0x…] → the public season (null while it is not public) and, with a wallet, its points. */
+export async function GET(req: Request) {
+ const w = (new URL(req.url).searchParams.get("wallet") ?? "").toLowerCase();
+ try {
+ const season = await memo("points:season", 10_000, currentSeason);
+ if (!season?.public) return NextResponse.json({ season: null, me: null }, { headers: { "cache-control": "no-store" } });
+ const me = isAddress(w) ? await memo(`points:me:${season.id}:${w}`, 10_000, () => walletPoints(season.id, w)) : null;
+ return NextResponse.json({ season: { name: season.name, starts_at: season.starts_at, ends_at: season.ends_at }, me }, { headers: { "cache-control": "no-store" } });
+ } catch (err) {
+ console.error("[points] read failed:", err instanceof Error ? err.message : err);
+ return NextResponse.json({ error: "could not load points" }, { status: 502 });
+ }
+}
diff --git a/app/src/app/leaderboard/page.tsx b/app/src/app/leaderboard/page.tsx
new file mode 100644
index 00000000..396339a4
--- /dev/null
+++ b/app/src/app/leaderboard/page.tsx
@@ -0,0 +1,89 @@
+import type { Metadata } from "next";
+import Link from "next/link";
+import { ArrowRight, Trophy } from "lucide-react";
+import shell from "@/components/sections/SectionShell.module.css";
+import NamesProvider from "@/components/profile/NamesProvider";
+import { WhoAvatar, WhoName } from "@/components/profile/Who";
+import YourStanding from "@/components/points/YourStanding";
+import { memo } from "@/lib/launchpad/memo";
+import { boardRows, currentSeason, type Board } from "@/lib/points/server";
+import { pageMetadata } from "@/lib/seo";
+
+export const dynamic = "force-dynamic";
+export const metadata: Metadata = pageMetadata({ path: "/leaderboard", title: "Leaderboard", description: "Season points for creators whose tokens find real buyers and traders who find them early. Computed from the chain every hour." });
+
+function daysLeft(endsAt: string): number {
+ return Math.max(0, Math.ceil((new Date(endsAt).getTime() - Date.now()) / 86_400_000));
+}
+
+export default async function LeaderboardPage({ searchParams }: { searchParams: Promise<{ board?: string }> }) {
+ const { board: raw } = await searchParams;
+ const board: Board = raw === "scout" ? "scout" : "creator";
+ const season = await memo("points:season", 10_000, currentSeason);
+
+ if (!season?.public) {
+ return (
+
+
+
Season points
+
Season 1 is almost here.
+
Points for creators whose tokens find real buyers, and for traders who find them early. Computed from the chain every hour. Create your profile and verify it with one post on X now, and your points unlock from day one.
No points yet this season. The board fills in as tokens find buyers.
+ ) : (
+
+ {data.rows.map((r) => (
+
+ {r.rank}
+
+
+
+ {r.why ?
{r.why}
: null}
+
+ {r.points.toLocaleString("en-US")}
+
+ ))}
+
+ )}
+
+
+
+ );
+}
diff --git a/app/src/app/rules/page.tsx b/app/src/app/rules/page.tsx
index ebb88eea..6e8e9915 100644
--- a/app/src/app/rules/page.tsx
+++ b/app/src/app/rules/page.tsx
@@ -1,7 +1,7 @@
import type { Metadata } from "next";
import { pageMetadata } from "@/lib/seo";
import Link from "next/link";
-import { ArrowDown, ArrowRight, ArrowUpRight, BookOpen, Check, ChevronDown, CircleAlert, Coins, FileCode2, LockKeyhole, Rocket } from "lucide-react";
+import { ArrowDown, ArrowRight, ArrowUpRight, BookOpen, Check, ChevronDown, CircleAlert, Coins, FileCode2, LockKeyhole, Rocket, Trophy } from "lucide-react";
import SectionIntro from "@/components/sections/SectionIntro";
import shell from "@/components/sections/SectionShell.module.css";
import styles from "@/components/sections/RulesGuide.module.css";
@@ -10,6 +10,10 @@ import { CHAINS, CHAIN_KEYS, CHAIN_LABELS, explorerAddress } from "@/lib/chainPu
import { BRAND_GITHUB } from "@/lib/brand";
import LaunchMachine from "@/components/launchpad/LaunchMachine";
import { ChainLogo } from "@/components/launchpad/ChainLogo";
+import { RULES } from "@/lib/points/score";
+import { eligibilityConfig } from "@/lib/points/server";
+
+const POINTS_X = eligibilityConfig();
export const metadata: Metadata = pageMetadata({ path: "/rules", title: "How it works", description: "What a launch does on-chain, what it costs (gas), and what can never happen to your liquidity." });
@@ -29,7 +33,7 @@ const FIXED = [
/** The guide's sections, each with the mark its heading carries. */
const CONTENTS = [
- ["launchpad", "The launch", Rocket], ["fees", "Fees & routing", Coins], ["immutable", "What stays fixed", LockKeyhole], ["know", "Before you begin", CircleAlert], ["contracts", "Contracts", FileCode2],
+ ["launchpad", "The launch", Rocket], ["fees", "Fees & routing", Coins], ["immutable", "What stays fixed", LockKeyhole], ["points", "Season points", Trophy], ["know", "Before you begin", CircleAlert], ["contracts", "Contracts", FileCode2],
] as const;
/** Verification records stay chain-specific and point to each deployed contract. */
@@ -112,6 +116,17 @@ export default function RulesPage() {
{FIXED.map(([title, description]) =>
{title}
{description}
)}
+
+
Season points
Points for real buyers. Nothing for noise.
See the leaderboard
+
Points measure reputation on openlaunch. They have no cash value, are not a token and not a promise of one, cannot be transferred or sold, and the rules can change between seasons. Wallets that game the system lose their points. Everything is computed from the chain every hour; the rules below are the whole of it.
+
+ How do creators earn?
On any token you launched, only what happens during the season counts. Each outside buyer whose first buy was this season and who still holds a day later earns you {RULES.verifiedHolder} points if their profile is verified on X, or {RULES.holder} if not (at least ${RULES.verifiedHolderMinUsd} or ${RULES.holderMinUsd} held). Still holding at day 7 adds half again.
Trading fees from outside trades earn {RULES.creatorFeePointsPerUsd} points per $1, whoever the fee goes to. A token that is a week old, has {RULES.aliveMinHolders}+ outside holders and an outside trade in the last day earns {RULES.alive} once.
Of the tokens you launch on one day, only your best {RULES.bestTokensPerDay} count. A token scores nothing if, during the season, you sell half or more of what you bought of it.
+ How do traders earn?
Be one of the first {RULES.earlyFirstBuyers} outside buyers of a token that goes on to reach {RULES.earlyHoldersTarget} outside holders: {RULES.early} points, and {RULES.earlyStillHolding} more while you still hold it.
Every token you buy (${RULES.minTradeUsd}+) and still hold a day later: {RULES.hold} points, up to {RULES.holdTokensPerDay} tokens a day. Fees you pay on other people's tokens with {RULES.scoutFeeMinHolders}+ outside holders: {RULES.scoutFeePointsPerUsd} points per $1, up to {RULES.scoutFeeCapPerTokenDay} per token a day.
+ What never counts?
Launching by itself, trading your own token, trades under ${RULES.minTradeUsd}, buys in the launch block and the next {RULES.sniperBlocks} blocks, wallets that got a token by transfer from its launcher or fee recipient, and the launcher and fee recipients themselves.
+ Who appears on the leaderboard?
Profiles verified with one post on X from an account at least {POINTS_X.minAgeDays} days old with {POINTS_X.minFollowers} or more followers. Everyone else can see the points waiting for them on their dashboard; they unlock, for the whole season so far, the moment the profile is verified. The post itself earns nothing.
+
+
+
Before you begin
Locked liquidity. Not a guarantee of value.
Tokens launched here are created by their launchers, not by openlaunch. Do your own research; a locked pool does not make a token valuable. Nothing is refundable.
diff --git a/app/src/app/u/[username]/page.tsx b/app/src/app/u/[username]/page.tsx
index 3d2cbeb4..45397abd 100644
--- a/app/src/app/u/[username]/page.tsx
+++ b/app/src/app/u/[username]/page.tsx
@@ -18,6 +18,8 @@ import { fmtUsd } from "@/lib/launchpad/math";
import { ago, nowMs } from "@/lib/launchpad/time";
import { CHAIN_SHORT, SITE_URL, explorerTx } from "@/lib/chainPublic";
import { BRAND_DOMAIN, BRAND_X } from "@/lib/brand";
+import { currentSeason, walletPoints } from "@/lib/points/server";
+import { Trophy } from "lucide-react";
export const dynamic = "force-dynamic";
@@ -59,6 +61,10 @@ export default async function ProfilePage({ params }: { params: Promise<{ userna
]);
// the owner's name comes from the profile this page just read, not from a separately cached lookup
const ownName = { u: p.username, d: p.display_name, a: p.avatar_url, v: p.x_state === "verified" };
+ // season points, once the season is public, for a wallet that qualifies
+ const season = await memo("points:season", 10_000, currentSeason);
+ const pts = season?.public ? await memo(`points:me:${season.id}:${p.wallet}`, 10_000, () => walletPoints(season.id, p.wallet)) : null;
+ const showPts = Boolean(season && pts && pts.eligible && pts.total > 0);
const now = nowMs();
const joined = new Date(p.created_at).toLocaleDateString("en-US", { month: "short", year: "numeric", timeZone: "UTC" });
@@ -99,6 +105,13 @@ export default async function ProfilePage({ params }: { params: Promise<{ userna
))}
+ {showPts && season && pts ? (
+
+ {season.name}
+ {pts.creator > 0 ? Creator {pts.rank_creator ? #{pts.rank_creator} : null} · {pts.creator.toLocaleString("en-US")} pts : null}
+ {pts.scout > 0 ? Scout {pts.rank_scout ? #{pts.rank_scout} : null} · {pts.scout.toLocaleString("en-US")} pts : null}
+
+ ) : null}
Every number here is read from the chain. {p.x ? "The ✓ means this wallet posted a one-time code from that X account." : "No ✓: this profile has not verified an X account."}
diff --git a/app/src/components/launchpad/MeDashboard.tsx b/app/src/components/launchpad/MeDashboard.tsx
index 5c2d245c..7bcd0723 100644
--- a/app/src/components/launchpad/MeDashboard.tsx
+++ b/app/src/components/launchpad/MeDashboard.tsx
@@ -28,6 +28,7 @@ import { friendlyError } from "@/lib/errors";
import { SkRow, SkStat } from "@/components/Skeleton";
import ConnectWallet from "@/components/ConnectWallet";
import ProfileIdentity from "@/components/profile/ProfileIdentity";
+import SeasonCard from "@/components/points/SeasonCard";
import styles from "./MeDashboard.module.css";
type Me = { wallet: string; ethUsd: number | null; launches: LaunchRow[]; tokens: (LaunchRow & { my_buys: number; my_sells: number; my_last_trade: string })[]; trades: WalletTrade[] };
@@ -208,6 +209,7 @@ function WalletDashboard({ address, isConnected }: { address: Address | undefine
return (
{walletBar}
+
{err ?
Refresh failed: {err}. Showing the last loaded data. Try Refresh again.
: null}
} v={String(me.launches.length)} hint="Across all chains" />
diff --git a/app/src/components/points/PointsAdmin.tsx b/app/src/components/points/PointsAdmin.tsx
new file mode 100644
index 00000000..1e92a241
--- /dev/null
+++ b/app/src/components/points/PointsAdmin.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import { useState } from "react";
+import { useAccount, useConfig } from "wagmi";
+import { getWalletClient } from "wagmi/actions";
+import { useHydratedAccount } from "@/lib/useHydratedAccount";
+import { btn, card } from "@/components/ui";
+import { buildPointsAdminMessage, type PointsAdminAction } from "@/lib/points/auth";
+import type { NameEntry } from "@/lib/profiles/server";
+import { CHAINS, DEFAULT_CHAIN, shortAddr, type ChainKey } from "@/lib/chainPublic";
+import { nowMs } from "@/lib/launchpad/time";
+import { friendlyError } from "@/lib/errors";
+
+type Row = { rank: number; wallet: string; points: number; eligible: boolean; why: string };
+type Preview = { season: { name: string; starts_at: string; ends_at: string; public: boolean } | null; creator?: Row[]; scout?: Row[]; names?: Record; computed_at?: string | null; wallets?: number; eligible?: number; at: number };
+
+function nonce(): string {
+ const b = new Uint8Array(16);
+ crypto.getRandomValues(b);
+ return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
+}
+
+/**
+ * Season controls for an admin: start a season (not public), see the shadow boards (eligible or not, with why), publish
+ * when the trial week looks right, recompute now, end early. Every action is an admin signature.
+ */
+export default function PointsAdmin() {
+ const { address } = useHydratedAccount();
+ const { chainId } = useAccount();
+ const config = useConfig();
+ const chain: ChainKey = (Object.keys(CHAINS) as ChainKey[]).find((k) => CHAINS[k].id === chainId) ?? DEFAULT_CHAIN;
+ const [data, setData] = useState(null);
+ const [err, setErr] = useState(null);
+ const [busy, setBusy] = useState(false);
+
+ async function call(action: PointsAdminAction, extra: Record = {}) {
+ if (!address) return null;
+ const n = nonce();
+ const ts = nowMs();
+ const wallet = await getWalletClient(config);
+ const signature = await wallet.signMessage({ message: buildPointsAdminMessage({ action, wallet: address, nonce: n, ts }) });
+ const res = await fetch("/api/points/admin", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action, chain, wallet: address, nonce: n, ts, signature, ...extra }) });
+ const d = (await res.json()) as Record & { error?: string };
+ if (!res.ok) throw new Error(d.error ?? "failed");
+ return d;
+ }
+ async function run(action: PointsAdminAction, extra: Record = {}) {
+ setBusy(true);
+ setErr(null);
+ try {
+ if (action !== "preview") await call(action, extra);
+ setData({ ...((await call("preview")) as Omit), at: Date.now() });
+ } catch (e) {
+ setErr(friendlyError(e));
+ } finally {
+ setBusy(false);
+ }
+ }
+
+ if (!address) return null;
+ const s = data?.season ?? null;
+ const running = Boolean(s && data && new Date(s.ends_at).getTime() > data.at);
+ const list = (title: string, rows: Row[] | undefined) => (
+
{list("Creators (top 50, eligible or not)", data.creator)}{list("Scouts (top 50, eligible or not)", data.scout)}
: null}
+ >
+ ) : null}
+
+ );
+}
diff --git a/app/src/components/points/SeasonCard.tsx b/app/src/components/points/SeasonCard.tsx
new file mode 100644
index 00000000..96dc3b17
--- /dev/null
+++ b/app/src/components/points/SeasonCard.tsx
@@ -0,0 +1,39 @@
+"use client";
+
+import Link from "next/link";
+import { ArrowRight, Trophy } from "lucide-react";
+import { usePoints } from "./usePoints";
+
+/**
+ * /me: this wallet's season points. Eligible → points and ranks. Not yet → the points waiting for it, and the one
+ * step that unlocks them (a verified profile). Nothing at all while no season is public.
+ */
+export default function SeasonCard({ address }: { address: string }) {
+ const p = usePoints(address);
+ if (!p?.season) return null;
+ const me = p.me;
+ const days = Math.max(0, Math.ceil((new Date(p.season.ends_at).getTime() - p.at) / 86_400_000));
+ const cell = (label: string, points: number, rank: number | null, why: string) => (
+
{me.total.toLocaleString("en-US")} points are waiting for you. Verify your profile with one post on X (an account at least a month old) and they unlock for the whole season so far.
No points yet this season. Launch something people hold, or find tokens early and hold them. How points work
+ )}
+
+ );
+}
diff --git a/app/src/components/points/YourStanding.tsx b/app/src/components/points/YourStanding.tsx
new file mode 100644
index 00000000..d5ff5f8c
--- /dev/null
+++ b/app/src/components/points/YourStanding.tsx
@@ -0,0 +1,25 @@
+"use client";
+
+import Link from "next/link";
+import { useHydratedAccount } from "@/lib/useHydratedAccount";
+import { usePoints } from "./usePoints";
+
+/** The connected wallet's place on the board in view, or what is waiting for it until its profile is verified. */
+export default function YourStanding({ board }: { board: "creator" | "scout" }) {
+ const { address } = useHydratedAccount();
+ const p = usePoints(address);
+ if (!address || !p?.season || !p.me) return null;
+ const me = p.me;
+ const points = board === "creator" ? me.creator : me.scout;
+ const rank = board === "creator" ? me.rank_creator : me.rank_scout;
+ const why = board === "creator" ? me.why_creator : me.why_scout;
+ return (
+
+
+
You
+
{me.eligible ? (rank ? <>Rank #{rank}> : "Not ranked on this board yet") : <>Your points are waiting. Verify your profile with X to unlock them.>}{why ? · {why} : null}
+
+ {points.toLocaleString("en-US")}
+
+ );
+}
diff --git a/app/src/components/points/usePoints.ts b/app/src/components/points/usePoints.ts
new file mode 100644
index 00000000..5ffb622d
--- /dev/null
+++ b/app/src/components/points/usePoints.ts
@@ -0,0 +1,27 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { WalletPoints } from "@/lib/points/server";
+
+export type PointsState = { season: { name: string; starts_at: string; ends_at: string } | null; me: WalletPoints | null; at: number } | null;
+
+/** The public season and this wallet's points (null season while the season is not public). */
+export function usePoints(wallet: string | undefined): PointsState {
+ const [state, setState] = useState(null);
+ useEffect(() => {
+ let alive = true;
+ const id = setTimeout(async () => {
+ try {
+ const r = await fetch(`/api/points${wallet ? `?wallet=${wallet}` : ""}`, { cache: "no-store" });
+ if (r.ok && alive) setState({ ...((await r.json()) as Omit, "at">), at: Date.now() });
+ } catch {
+ /* points are a side panel: say nothing on a blip */
+ }
+ }, 0);
+ return () => {
+ alive = false;
+ clearTimeout(id);
+ };
+ }, [wallet]);
+ return state;
+}
diff --git a/app/src/components/sections/rules-guide.test.ts b/app/src/components/sections/rules-guide.test.ts
index e0def860..946dc505 100644
--- a/app/src/components/sections/rules-guide.test.ts
+++ b/app/src/components/sections/rules-guide.test.ts
@@ -9,10 +9,11 @@ const css = readFileSync(new URL("./RulesGuide.module.css", import.meta.url), "u
test("the rules guide retains native navigation and visible risk disclosures", () => {
assert.match(page, /import SectionIntro from/);
assert.match(page, /aria-label="On this page"/);
- for (const anchor of ["launchpad", "fees", "immutable", "know", "contracts"]) {
+ for (const anchor of ["launchpad", "fees", "immutable", "points", "know", "contracts"]) {
assert.ok(page.includes(`id="${anchor}"`), `missing section: ${anchor}`);
}
- assert.equal((page.match(//g) ?? []).length, 5);
+ assert.equal((page.match(//g) ?? []).length, 9); // 4 on season points, 5 before you begin
+ assert.match(page, /no cash value, are not a token and not a promise of one/, "points are reputation, said where the rules are");
assert.match(page, /quiet launches/);
assert.match(page, /every launch stays in the New tab/i);
assert.match(page, /GITLAWB-quoted pool/);
diff --git a/app/src/lib/launchpad/loop.ts b/app/src/lib/launchpad/loop.ts
index 8173ad49..0af84203 100644
--- a/app/src/lib/launchpad/loop.ts
+++ b/app/src/lib/launchpad/loop.ts
@@ -1,6 +1,7 @@
import "server-only";
import { pollAll } from "./indexer";
import { recheckProfiles } from "@/lib/profiles/server";
+import { computePointsIfDue } from "@/lib/points/server";
/** In-process launchpad poller (same shape as src/lib/syncLoop.ts). */
const DEFAULT_MS = 15_000;
@@ -29,6 +30,8 @@ export function startLaunchSyncLoop(): boolean {
}
// verified X posts are re-read about weekly, a small batch every ten minutes (self-throttled)
await recheckProfiles().catch((err) => console.warn("[profiles] recheck:", err instanceof Error ? err.message : err));
+ // season points: recomputed hourly from the index, one machine at a time (self-throttled)
+ await computePointsIfDue().catch((err) => console.warn("[points] compute:", err instanceof Error ? err.message : err));
};
const timer = setInterval(() => void tick(), interval);
timer.unref();
diff --git a/app/src/lib/launchpad/queries.ts b/app/src/lib/launchpad/queries.ts
index 2ff9e856..95578d9f 100644
--- a/app/src/lib/launchpad/queries.ts
+++ b/app/src/lib/launchpad/queries.ts
@@ -784,3 +784,15 @@ export async function getTrending(ethUsd: number | null = null): Promise { decimals: number; usd: number | null }> {
+ await withStocks();
+ return (chain, quote) => {
+ const q = quoteInfo(chain, quote);
+ return { decimals: q.decimals, usd: quoteUsd(q, ethUsd) };
+ };
+}
diff --git a/app/src/lib/points/auth.ts b/app/src/lib/points/auth.ts
new file mode 100644
index 00000000..4595bdc2
--- /dev/null
+++ b/app/src/lib/points/auth.ts
@@ -0,0 +1,10 @@
+/** Signed admin messages for season points (pure; node --test loads this directly). */
+export const POINTS_ADMIN_ACTIONS = ["preview", "start", "publish", "unpublish", "end", "recompute"] as const;
+export type PointsAdminAction = (typeof POINTS_ADMIN_ACTIONS)[number];
+export function isPointsAdminAction(v: unknown): v is PointsAdminAction {
+ return typeof v === "string" && (POINTS_ADMIN_ACTIONS as readonly string[]).includes(v);
+}
+
+export function buildPointsAdminMessage(p: { action: PointsAdminAction; wallet: string; nonce: string; ts: number }): string {
+ return [`openlaunch.lol points`, ``, `Action: ${p.action}`, `Admin: ${p.wallet.toLowerCase()}`, `Nonce: ${p.nonce}`, `Issued: ${new Date(p.ts).toISOString()}`].join("\n");
+}
diff --git a/app/src/lib/points/score.test.ts b/app/src/lib/points/score.test.ts
new file mode 100644
index 00000000..8502e1a2
--- /dev/null
+++ b/app/src/lib/points/score.test.ts
@@ -0,0 +1,160 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { RULES, rankBy, scoreSeason, whyLine, type FirstBuy, type ScoreInput, type ScoreLaunch, type ScoreSwap } from "./score.ts";
+
+const DAY = 86_400_000;
+const START = Date.UTC(2026, 9, 1);
+const NOW = START + 10 * DAY;
+const CREATOR = "0xc0";
+const RECIP = "0xfe";
+const E18 = 10n ** 18n;
+const eth = (n: number) => BigInt(Math.round(n * 1e6)) * 10n ** 12n; // n ETH in wei
+const w = (i: number) => `0x${i.toString(16).padStart(4, "0")}`;
+
+// a token launched before the season: quote ETH at $2,000, 1% fee, token price $0.001
+const L: ScoreLaunch = { key: "base:0xt1", launcher: CREATOR, recipients: [RECIP], launchBlock: 100, launchTime: START - 30 * DAY, lpFee: 10_000, quoteDecimals: 18, quoteUsd: 2000, tokenUsd: 0.001 };
+
+function input(over: Partial = {}): ScoreInput {
+ return {
+ seasonStart: START,
+ seasonEnd: START + 28 * DAY,
+ now: NOW,
+ launches: [L],
+ swaps: [],
+ firstBuys: [],
+ holders: [],
+ launcherBought: new Map(),
+ linked: new Map(),
+ system: new Set(["0xpool"]),
+ eligible: new Set(),
+ flagged: new Set(),
+ maxOutsideBefore: new Map(),
+ ...over,
+ };
+}
+/** a buyer who bought at `t`, spent `ethIn`, and holds `tokens` whole tokens now */
+function buyer(wallet: string, t: number, ethIn: number, tokens: number, block = 1000) {
+ const s: ScoreSwap = { key: L.key, trader: wallet, isBuy: true, quoteRaw: eth(ethIn), tokenRaw: BigInt(tokens) * E18, block, time: t };
+ const f: FirstBuy = { key: L.key, wallet, block, time: t, quoteRaw: eth(ethIn) };
+ return { swap: s, first: f, holder: { key: L.key, wallet, balanceRaw: BigInt(tokens) * E18 } };
+}
+function build(buyers: ReturnType[], over: Partial = {}) {
+ return input({ swaps: buyers.map((b) => b.swap), firstBuys: buyers.map((b) => b.first), holders: buyers.map((b) => b.holder), ...over });
+}
+
+test("an eligible holder who bought this season and held a day earns the creator 30; unverified 5", () => {
+ const b1 = buyer(w(1), START + DAY, 0.01, 5000); // $20 spent, $5 held
+ const b2 = buyer(w(2), START + DAY, 0.01, 5000);
+ const r = scoreSeason(build([b1, b2], { eligible: new Set([w(1)]) }));
+ const c = r.wallets.get(CREATOR)!;
+ // 30 × 1.5 (held 9 days ≥ 7) + 5 × 1.5 + fees: 2 × $20 × 1% × 10
+ assert.equal(c.creatorWhy.verifiedHolders, 1);
+ assert.equal(c.creatorWhy.holders, 1);
+ assert.equal(c.creator, Math.round(45 + 7.5 + 2 * 20 * 0.01 * 10));
+});
+
+test("holders gained before the season, held under a day, or holding dust earn nothing", () => {
+ const before = buyer(w(1), START - DAY, 0.01, 5000);
+ const fresh = buyer(w(2), NOW - 3_600_000, 0.01, 5000);
+ const dust = buyer(w(3), START + DAY, 0.01, 1000); // $1 held, unverified needs $5
+ const r = scoreSeason(build([before, fresh, dust]));
+ const c = r.wallets.get(CREATOR);
+ assert.equal(c?.creatorWhy.holders ?? 0, 0);
+});
+
+test("the creator, fee recipients, transfer-fed wallets and snipers are never outside", () => {
+ const self = buyer(CREATOR, START + DAY, 1, 50000);
+ const recip = buyer(RECIP, START + DAY, 1, 50000);
+ const sock = buyer(w(9), START + DAY, 1, 50000);
+ const sniper = buyer(w(8), START + DAY, 1, 50000, L.launchBlock + 2);
+ const r = scoreSeason(build([self, recip, sock, sniper], { linked: new Map([[L.key, new Set([w(9)])]]), eligible: new Set([w(9), w(8)]) }));
+ assert.equal(r.wallets.get(CREATOR)?.creator ?? 0, 0, "no holder points and no fee points from insiders");
+});
+
+test("wash trading your own token earns nothing; a 0% fee token earns no fee points", () => {
+ const wash = Array.from({ length: 20 }, (_, i) => ({ key: L.key, trader: CREATOR, isBuy: i % 2 === 0, quoteRaw: eth(5), tokenRaw: 1000n * E18, block: 2000 + i, time: START + DAY + i }));
+ assert.equal(scoreSeason(input({ swaps: wash })).wallets.get(CREATOR)?.creator ?? 0, 0);
+ const free = { ...L, lpFee: 0 };
+ const b = buyer(w(1), START + DAY, 1, 1);
+ assert.equal(scoreSeason(build([b], { launches: [free], holders: [] })).wallets.get(CREATOR)?.creatorWhy.feesUsd ?? 0, 0);
+});
+
+test("a creator who sold half of what they bought this season scores 0 for that token; selling fee tokens alone is fine", () => {
+ const b = buyer(w(1), START + DAY, 0.5, 5000);
+ const dump: ScoreSwap = { key: L.key, trader: CREATOR, isBuy: false, quoteRaw: eth(1), tokenRaw: 600n * E18, block: 3000, time: START + 2 * DAY };
+ const dumped = scoreSeason(build([b], { swaps: [b.swap, dump], launcherBought: new Map([[L.key, 1000n * E18]]) }));
+ assert.equal(dumped.wallets.get(CREATOR)?.creator ?? 0, 0);
+ assert.equal(dumped.wallets.get(CREATOR)?.creatorWhy.dumped, 1);
+ const feeSale = scoreSeason(build([b], { swaps: [b.swap, dump] })); // never bought: those were fee tokens
+ assert.ok((feeSale.wallets.get(CREATOR)?.creator ?? 0) > 0);
+});
+
+test("only the best 3 tokens a wallet launched on one day count", () => {
+ const launches = Array.from({ length: 5 }, (_, i) => ({ ...L, key: `base:0xt${i}`, launchTime: START + DAY + i }));
+ const swaps: ScoreSwap[] = launches.map((l, i) => ({ key: l.key, trader: w(i + 1), isBuy: true, quoteRaw: eth(0.01 * (i + 1)), tokenRaw: E18, block: 5000, time: START + 2 * DAY }));
+ const r = scoreSeason(input({ launches, swaps }));
+ assert.equal(r.wallets.get(CREATOR)!.creatorWhy.tokens, 3);
+ // fees: $20, $40 … $100 at 1% → 2, 4, 6, 8, 10 points; best three = 10 + 8 + 6
+ assert.equal(r.wallets.get(CREATOR)!.creator, 24);
+});
+
+test("alive at day 7: a week old, 10+ outside holders, an outside trade in the last day", () => {
+ const holders = Array.from({ length: 10 }, (_, i) => buyer(w(i + 1), NOW - 2 * 3_600_000, 0.01, 5000));
+ const r = scoreSeason(build(holders));
+ assert.equal(r.wallets.get(CREATOR)!.creatorWhy.alive, 1);
+});
+
+test("early: the first 25 outside buyers of a token that reached 50 holders, +50 while holding", () => {
+ const buyers = Array.from({ length: 30 }, (_, i) => buyer(w(i + 1), START + DAY + i * 1000, 0.01, i === 0 ? 0 : 5000, 1000 + i));
+ const inp = build(buyers, { maxOutsideBefore: new Map([[L.key, 50]]) });
+ const r = scoreSeason(inp);
+ assert.equal(r.wallets.get(w(1))!.scoutWhy.early, 1);
+ assert.equal(r.wallets.get(w(2))!.scout - r.wallets.get(w(1))!.scout, RULES.earlyStillHolding + RULES.hold, "w(1) sold: no +50 and no hold");
+ assert.equal(r.wallets.get(w(26))?.scoutWhy.early ?? 0, 0, "26th buyer is not early");
+ const small = scoreSeason(build(buyers));
+ assert.equal(small.wallets.get(w(2))?.scoutWhy.early ?? 0, 0, "token never reached 50: nobody is early");
+});
+
+test("holds: ≥ $5 buy still held a day later, at most 20 tokens per day", () => {
+ const launches = Array.from({ length: 25 }, (_, i) => ({ ...L, key: `base:0xh${i}` }));
+ const swaps = launches.map((l) => ({ key: l.key, trader: w(1), isBuy: true, quoteRaw: eth(0.01), tokenRaw: 5000n * E18, block: 5000, time: START + DAY }));
+ const holders = launches.map((l) => ({ key: l.key, wallet: w(1), balanceRaw: 5000n * E18 }));
+ const r = scoreSeason(input({ launches, swaps, holders }));
+ assert.equal(r.wallets.get(w(1))!.scoutWhy.holds, RULES.holdTokensPerDay);
+ const tiny = scoreSeason(input({ launches: [L], swaps: [{ ...swaps[0], key: L.key, quoteRaw: eth(0.001) }], holders: [{ key: L.key, wallet: w(1), balanceRaw: E18 }] }));
+ assert.equal(tiny.wallets.get(w(1))?.scoutWhy.holds ?? 0, 0, "$2 buy earns nothing");
+});
+
+test("scout fee points only on tokens with 20+ outside holders, capped per token per day", () => {
+ const big: ScoreSwap[] = Array.from({ length: 40 }, (_, i) => ({ key: L.key, trader: w(1), isBuy: i % 2 === 0, quoteRaw: eth(1), tokenRaw: E18, block: 6000 + i, time: START + 2 * DAY + i }));
+ const r = scoreSeason(input({ swaps: big, maxOutsideBefore: new Map([[L.key, 20]]) }));
+ assert.equal(Math.round(r.wallets.get(w(1))!.scout), RULES.scoutFeeCapPerTokenDay);
+ const r2 = scoreSeason(input({ swaps: big }));
+ assert.equal(r2.wallets.get(w(1))?.scout ?? 0, 0, "token without 20 outside holders");
+});
+
+test("a wallet kept off points scores nothing and counts for no one", () => {
+ const b = buyer(w(1), START + DAY, 0.01, 5000);
+ const r = scoreSeason(build([b], { flagged: new Set([w(1)]), eligible: new Set([w(1)]) }));
+ assert.equal(r.wallets.has(w(1)), false);
+ assert.equal(r.wallets.get(CREATOR)?.creatorWhy.verifiedHolders ?? 0, 0);
+});
+
+test("unpriced quotes: holders count without a dollar floor, fees earn nothing", () => {
+ const unpriced = { ...L, quoteUsd: null, tokenUsd: null };
+ const b = buyer(w(1), START + DAY, 0.0001, 1);
+ const r = scoreSeason(build([b], { launches: [unpriced] }));
+ assert.equal(r.wallets.get(CREATOR)!.creatorWhy.holders, 1);
+ assert.equal(r.wallets.get(CREATOR)!.creatorWhy.feesUsd, 0);
+});
+
+test("ranks only eligible wallets, ties share a rank; why lines read plainly", () => {
+ const scores = [
+ { wallet: "a", creator: 50, scout: 0, total: 50, creatorWhy: { tokens: 1, verifiedHolders: 2, holders: 1, feesUsd: 12.5, alive: 1, dumped: 0 }, scoutWhy: { early: 0, holds: 0, feesUsd: 0 } },
+ { wallet: "b", creator: 50, scout: 0, total: 50, creatorWhy: { tokens: 1, verifiedHolders: 0, holders: 0, feesUsd: 0, alive: 0, dumped: 0 }, scoutWhy: { early: 0, holds: 0, feesUsd: 0 } },
+ { wallet: "c", creator: 90, scout: 0, total: 90, creatorWhy: { tokens: 1, verifiedHolders: 0, holders: 0, feesUsd: 0, alive: 0, dumped: 0 }, scoutWhy: { early: 0, holds: 0, feesUsd: 0 } },
+ ];
+ const ranks = rankBy(scores, "creator", new Set(["a", "b"]));
+ assert.deepEqual([...ranks.entries()], [["a", 1], ["b", 1]], "c is not eligible");
+ assert.equal(whyLine("creator", scores[0]), "2 verified holders · 1 holder · $12.50 fees · 1 alive at day 7");
+});
diff --git a/app/src/lib/points/score.ts b/app/src/lib/points/score.ts
new file mode 100644
index 00000000..e64db813
--- /dev/null
+++ b/app/src/lib/points/score.ts
@@ -0,0 +1,337 @@
+/**
+ * Season points, the pure half (node --test loads this directly; relative imports only).
+ *
+ * Points are reputation for launching and trading, recomputed from scratch from the chain index. The rules reward what
+ * costs real money or reputation to fake and nothing that is free to repeat:
+ *
+ * Creator (per token the wallet launched; any token, only activity inside the season counts)
+ * - each outside buyer whose first buy was in the season and still holds after 24 h: 30 if their wallet is
+ * points-eligible (X-verified, account age and followers), else 5; the eligible holder needs ≥ $1 held,
+ * the other ≥ $5; still holding at day 7 adds half again
+ * - 10 per $1 of trading fees generated by outside trades in the season (the fee exists whoever it is paid to)
+ * - 100 once if the token is a week old, has ≥ 10 outside holders and an outside trade in the last day
+ * - the token scores 0 if, during the season, the creator sold half or more of what they ever bought of it
+ * - of the tokens a wallet launched on one UTC day, only the best 3 count
+ * Scout (traders)
+ * - 100 for being one of the first 25 outside buyers (first buy ≥ $5, in the season) of a token that has reached
+ * 50 outside holders; 50 more while still holding
+ * - 5 per token bought (≥ $5, in the season) and still held 24 h later; at most 20 tokens per UTC day
+ * - 10 per $1 of fees paid on other people's tokens that have ≥ 20 outside holders; at most 200 per token per day
+ *
+ * "Outside" = not the launcher, not a fee recipient, not a protocol address, not a wallet that got the token by
+ * transfer from the launcher or a recipient, not a wallet an admin kept off points, and never a buy inside the
+ * sniper window (launch block + SNIPER_BLOCKS). Trades under $5 earn nothing.
+ */
+
+export const RULES = {
+ sniperBlocks: 3,
+ minTradeUsd: 5,
+ verifiedHolder: 30,
+ verifiedHolderMinUsd: 1,
+ holder: 5,
+ holderMinUsd: 5,
+ day7Bonus: 0.5,
+ creatorFeePointsPerUsd: 10,
+ alive: 100,
+ aliveMinHolders: 10,
+ aliveAgeMs: 7 * 86_400_000,
+ bestTokensPerDay: 3,
+ early: 100,
+ earlyStillHolding: 50,
+ earlyFirstBuyers: 25,
+ earlyHoldersTarget: 50,
+ hold: 5,
+ holdTokensPerDay: 20,
+ scoutFeePointsPerUsd: 10,
+ scoutFeeCapPerTokenDay: 200,
+ scoutFeeMinHolders: 20,
+} as const;
+
+const DAY = 86_400_000;
+
+export type ScoreLaunch = {
+ key: string; // chain:token
+ launcher: string;
+ recipients: readonly string[];
+ launchBlock: number;
+ launchTime: number; // ms
+ lpFee: number; // pips (10_000 = 1%)
+ quoteDecimals: number;
+ quoteUsd: number | null; // USD per whole quote unit; null = unpriced (no USD-gated points)
+ tokenUsd: number | null; // USD per whole token now
+};
+export type ScoreSwap = { key: string; trader: string; isBuy: boolean; quoteRaw: bigint; tokenRaw: bigint; block: number; time: number };
+/** A wallet's first buy of a token, all-time (rank among outside buyers decides "early"). */
+export type FirstBuy = { key: string; wallet: string; block: number; time: number; quoteRaw: bigint };
+
+export type ScoreInput = {
+ seasonStart: number;
+ seasonEnd: number;
+ now: number;
+ launches: readonly ScoreLaunch[];
+ /** swaps inside the season, any order */
+ swaps: readonly ScoreSwap[];
+ firstBuys: readonly FirstBuy[];
+ /** current balances (raw, 18 decimals) of the tokens in scope */
+ holders: readonly { key: string; wallet: string; balanceRaw: bigint }[];
+ /** the launcher's all-time bought amount (raw) per token */
+ launcherBought: ReadonlyMap;
+ /** per token: wallets that received it by transfer from the launcher or a fee recipient */
+ linked: ReadonlyMap>;
+ /** protocol addresses (pool manager, routers, locker, factory, zero, dead) */
+ system: ReadonlySet;
+ eligible: ReadonlySet;
+ /** kept off points by an admin: never points, never counted as anyone's outside holder or buyer */
+ flagged: ReadonlySet;
+ /** the most outside holders each token had earlier this season */
+ maxOutsideBefore: ReadonlyMap;
+};
+
+export type CreatorBreakdown = { tokens: number; verifiedHolders: number; holders: number; feesUsd: number; alive: number; dumped: number };
+export type ScoutBreakdown = { early: number; holds: number; feesUsd: number };
+export type WalletScore = { wallet: string; creator: number; scout: number; total: number; creatorWhy: CreatorBreakdown; scoutWhy: ScoutBreakdown };
+export type ScoreResult = { wallets: Map; maxOutside: Map };
+
+const units = (raw: bigint, decimals: number) => Number(raw) / 10 ** decimals;
+const utcDay = (ms: number) => Math.floor(ms / DAY);
+
+export function scoreSeason(input: ScoreInput): ScoreResult {
+ const end = Math.min(input.now, input.seasonEnd);
+ const launches = new Map(input.launches.map((l) => [l.key, l]));
+ const wallets = new Map();
+ const get = (w: string) => {
+ let s = wallets.get(w);
+ if (!s) {
+ s = { wallet: w, creator: 0, scout: 0, total: 0, creatorWhy: { tokens: 0, verifiedHolders: 0, holders: 0, feesUsd: 0, alive: 0, dumped: 0 }, scoutWhy: { early: 0, holds: 0, feesUsd: 0 } };
+ wallets.set(w, s);
+ }
+ return s;
+ };
+
+ const insider = (l: ScoreLaunch, w: string) => w === l.launcher || l.recipients.includes(w) || input.system.has(w) || input.flagged.has(w) || (input.linked.get(l.key)?.has(w) ?? false);
+ const usdOfQuote = (l: ScoreLaunch, raw: bigint) => (l.quoteUsd === null ? null : units(raw, l.quoteDecimals) * l.quoteUsd);
+ const usdOfTokens = (l: ScoreLaunch, raw: bigint) => (l.tokenUsd === null ? null : units(raw, 18) * l.tokenUsd);
+ const inSeason = (t: number) => t >= input.seasonStart && t < end;
+
+ // first buys: the outside buyers of each token in buy order (sniper-window buys never count)
+ const firstByToken = new Map();
+ for (const f of input.firstBuys) {
+ const l = launches.get(f.key);
+ if (!l || insider(l, f.wallet) || f.block <= l.launchBlock + RULES.sniperBlocks) continue;
+ const list = firstByToken.get(f.key) ?? [];
+ list.push(f);
+ firstByToken.set(f.key, list);
+ }
+ for (const list of firstByToken.values()) list.sort((a, b) => a.block - b.block || a.time - b.time);
+ const firstOf = new Map();
+ for (const [key, list] of firstByToken) for (const f of list) firstOf.set(`${key}|${f.wallet}`, f);
+
+ // current outside holders per token
+ const balance = new Map();
+ const outsideHolders = new Map();
+ for (const h of input.holders) {
+ if (h.balanceRaw <= 0n) continue;
+ balance.set(`${h.key}|${h.wallet}`, h.balanceRaw);
+ const l = launches.get(h.key);
+ if (!l || insider(l, h.wallet)) continue;
+ const list = outsideHolders.get(h.key) ?? [];
+ list.push(h.wallet);
+ outsideHolders.set(h.key, list);
+ }
+ const maxOutside = new Map();
+ for (const l of input.launches) maxOutside.set(l.key, Math.max(input.maxOutsideBefore.get(l.key) ?? 0, outsideHolders.get(l.key)?.length ?? 0));
+
+ // the season's outside swaps, and what each token's creator sold
+ const outsideSwaps: ScoreSwap[] = [];
+ const lastOutsideTrade = new Map();
+ const creatorSold = new Map();
+ for (const s of input.swaps) {
+ const l = launches.get(s.key);
+ if (!l || !inSeason(s.time)) continue;
+ if (s.trader === l.launcher && !s.isBuy) creatorSold.set(s.key, (creatorSold.get(s.key) ?? 0n) + s.tokenRaw);
+ if (insider(l, s.trader) || s.block <= l.launchBlock + RULES.sniperBlocks) continue;
+ outsideSwaps.push(s);
+ lastOutsideTrade.set(s.key, Math.max(lastOutsideTrade.get(s.key) ?? 0, s.time));
+ }
+
+ // ── creator points, per token, then the best 3 per launcher per launch day ──
+ type TokenCreator = { l: ScoreLaunch; points: number; verifiedHolders: number; holders: number; feesUsd: number; alive: number; dumped: boolean };
+ const perToken = new Map();
+ const ensure = (l: ScoreLaunch) => {
+ let t = perToken.get(l.key);
+ if (!t) {
+ t = { l, points: 0, verifiedHolders: 0, holders: 0, feesUsd: 0, alive: 0, dumped: false };
+ perToken.set(l.key, t);
+ }
+ return t;
+ };
+ for (const [key, holdersList] of outsideHolders) {
+ const l = launches.get(key)!;
+ for (const w of holdersList) {
+ const f = firstOf.get(`${key}|${w}`);
+ if (!f || !inSeason(f.time) || end - f.time < DAY) continue; // gained this season, held a day
+ const eligible = input.eligible.has(w);
+ const value = usdOfTokens(l, balance.get(`${key}|${w}`) ?? 0n);
+ if (value !== null && value < (eligible ? RULES.verifiedHolderMinUsd : RULES.holderMinUsd)) continue;
+ const base = eligible ? RULES.verifiedHolder : RULES.holder;
+ const t = ensure(l);
+ t.points += base * (end - f.time >= 7 * DAY ? 1 + RULES.day7Bonus : 1);
+ if (eligible) t.verifiedHolders++;
+ else t.holders++;
+ }
+ }
+ for (const s of outsideSwaps) {
+ const l = launches.get(s.key)!;
+ const usd = usdOfQuote(l, s.quoteRaw);
+ if (usd === null) continue;
+ const fee = (usd * l.lpFee) / 1_000_000;
+ if (fee <= 0) continue;
+ const t = ensure(l);
+ t.feesUsd += fee;
+ t.points += fee * RULES.creatorFeePointsPerUsd;
+ }
+ for (const l of input.launches) {
+ if (end - l.launchTime < RULES.aliveAgeMs) continue;
+ if ((outsideHolders.get(l.key)?.length ?? 0) < RULES.aliveMinHolders) continue;
+ if (end - (lastOutsideTrade.get(l.key) ?? 0) > DAY) continue;
+ const t = ensure(l);
+ t.alive = 1;
+ t.points += RULES.alive;
+ }
+ for (const t of perToken.values()) {
+ const bought = input.launcherBought.get(t.l.key) ?? 0n;
+ const sold = creatorSold.get(t.l.key) ?? 0n;
+ // only against what the creator bought: selling the token-side fees the pool paid them is not a dump
+ if (bought > 0n && sold * 2n >= bought) {
+ t.dumped = true;
+ t.points = 0;
+ }
+ }
+ const byLauncherDay = new Map();
+ for (const t of perToken.values()) {
+ const k = `${t.l.launcher}|${utcDay(t.l.launchTime)}`;
+ const list = byLauncherDay.get(k) ?? [];
+ list.push(t);
+ byLauncherDay.set(k, list);
+ }
+ for (const list of byLauncherDay.values()) {
+ list.sort((a, b) => b.points - a.points);
+ list.forEach((t, i) => {
+ const w = t.l.launcher;
+ if (input.flagged.has(w)) return;
+ const s = get(w);
+ if (t.dumped) s.creatorWhy.dumped++;
+ if (i >= RULES.bestTokensPerDay || t.points <= 0) return;
+ s.creator += t.points;
+ s.creatorWhy.tokens++;
+ s.creatorWhy.verifiedHolders += t.verifiedHolders;
+ s.creatorWhy.holders += t.holders;
+ s.creatorWhy.feesUsd += t.feesUsd;
+ s.creatorWhy.alive += t.alive;
+ });
+ }
+
+ // ── scout points ──
+ for (const [key, list] of firstByToken) {
+ const l = launches.get(key)!;
+ if ((maxOutside.get(key) ?? 0) < RULES.earlyHoldersTarget) continue;
+ list.slice(0, RULES.earlyFirstBuyers).forEach((f) => {
+ if (!inSeason(f.time)) return;
+ const usd = usdOfQuote(l, f.quoteRaw);
+ if (usd !== null && usd < RULES.minTradeUsd) return;
+ const s = get(f.wallet);
+ s.scout += RULES.early + ((balance.get(`${key}|${f.wallet}`) ?? 0n) > 0n ? RULES.earlyStillHolding : 0);
+ s.scoutWhy.early++;
+ });
+ }
+ // holds: the first in-season buy of a token (≥ $5), still held a day later; at most 20 tokens per buy day
+ const holdsPerDay = new Map();
+ const firstSeasonBuy = new Map();
+ for (const s of outsideSwaps) {
+ if (!s.isBuy) continue;
+ const k = `${s.key}|${s.trader}`;
+ const cur = firstSeasonBuy.get(k);
+ if (!cur || s.time < cur.time) firstSeasonBuy.set(k, s);
+ }
+ for (const s of [...firstSeasonBuy.values()].sort((a, b) => a.time - b.time)) {
+ const l = launches.get(s.key)!;
+ const usd = usdOfQuote(l, s.quoteRaw);
+ if (usd !== null && usd < RULES.minTradeUsd) continue;
+ if (end - s.time < DAY || (balance.get(`${s.key}|${s.trader}`) ?? 0n) <= 0n) continue;
+ const dayKey = `${s.trader}|${utcDay(s.time)}`;
+ const n = holdsPerDay.get(dayKey) ?? 0;
+ if (n >= RULES.holdTokensPerDay) continue;
+ holdsPerDay.set(dayKey, n + 1);
+ const w = get(s.trader);
+ w.scout += RULES.hold;
+ w.scoutWhy.holds++;
+ }
+ // fees paid on other people's real tokens, capped per token per day
+ const feeCap = new Map();
+ for (const s of outsideSwaps) {
+ const l = launches.get(s.key)!;
+ if ((maxOutside.get(s.key) ?? 0) < RULES.scoutFeeMinHolders) continue;
+ const usd = usdOfQuote(l, s.quoteRaw);
+ if (usd === null || usd < RULES.minTradeUsd) continue;
+ const fee = (usd * l.lpFee) / 1_000_000;
+ if (fee <= 0) continue;
+ const capKey = `${s.trader}|${s.key}|${utcDay(s.time)}`;
+ const used = feeCap.get(capKey) ?? 0;
+ const pts = Math.min(fee * RULES.scoutFeePointsPerUsd, RULES.scoutFeeCapPerTokenDay - used);
+ if (pts <= 0) continue;
+ feeCap.set(capKey, used + pts);
+ const w = get(s.trader);
+ w.scout += pts;
+ w.scoutWhy.feesUsd += fee;
+ }
+
+ for (const w of [...wallets.keys()]) {
+ const s = wallets.get(w)!;
+ if (input.flagged.has(w)) {
+ wallets.delete(w);
+ continue;
+ }
+ s.creator = Math.round(s.creator);
+ s.scout = Math.round(s.scout);
+ s.total = s.creator + s.scout;
+ s.creatorWhy.feesUsd = Math.round(s.creatorWhy.feesUsd * 100) / 100;
+ s.scoutWhy.feesUsd = Math.round(s.scoutWhy.feesUsd * 100) / 100;
+ if (s.total <= 0 && s.creatorWhy.dumped === 0) wallets.delete(w);
+ }
+ return { wallets, maxOutside };
+}
+
+/** Ranks among eligible wallets (1 = best); ties share a rank. */
+export function rankBy(scores: readonly WalletScore[], field: "creator" | "scout", eligible: ReadonlySet): Map {
+ const list = scores.filter((s) => eligible.has(s.wallet) && s[field] > 0).sort((a, b) => b[field] - a[field] || a.wallet.localeCompare(b.wallet));
+ const out = new Map();
+ let rank = 0;
+ let prev = Number.NaN;
+ list.forEach((s, i) => {
+ if (s[field] !== prev) {
+ rank = i + 1;
+ prev = s[field];
+ }
+ out.set(s.wallet, rank);
+ });
+ return out;
+}
+
+/** One line of "why" for a leaderboard row, from the breakdown. */
+export function whyLine(board: "creator" | "scout", s: Pick): string {
+ const parts: string[] = [];
+ const usd = (n: number) => `$${n >= 100 ? Math.round(n).toLocaleString("en-US") : n.toFixed(2)}`;
+ if (board === "creator") {
+ const c = s.creatorWhy;
+ if (c.verifiedHolders) parts.push(`${c.verifiedHolders} verified holder${c.verifiedHolders === 1 ? "" : "s"}`);
+ if (c.holders) parts.push(`${c.holders} holder${c.holders === 1 ? "" : "s"}`);
+ if (c.feesUsd >= 0.01) parts.push(`${usd(c.feesUsd)} fees`);
+ if (c.alive) parts.push(`${c.alive} alive at day 7`);
+ } else {
+ const c = s.scoutWhy;
+ if (c.early) parts.push(`early on ${c.early} token${c.early === 1 ? "" : "s"}`);
+ if (c.holds) parts.push(`${c.holds} held a day+`);
+ if (c.feesUsd >= 0.01) parts.push(`${usd(c.feesUsd)} fees paid`);
+ }
+ return parts.join(" · ");
+}
diff --git a/app/src/lib/points/server.ts b/app/src/lib/points/server.ts
new file mode 100644
index 00000000..626be3d5
--- /dev/null
+++ b/app/src/lib/points/server.ts
@@ -0,0 +1,272 @@
+import "server-only";
+import { isAddress } from "viem";
+import { CHAIN_KEYS, chainKeyOf, type ChainKey } from "@/lib/chainPublic";
+import { maybeDb, type Db } from "@/lib/db";
+import { ethUsd } from "@/lib/launchpad/ethPrice";
+import { DEAD_ADDR, ZERO_ADDR } from "@/lib/launchpad/holders";
+import { systemAddresses } from "@/lib/launchpad/indexer";
+import { quotePerToken } from "@/lib/launchpad/math";
+import { quotePricer } from "@/lib/launchpad/queries";
+import { pointsEligible } from "@/lib/profiles/xpost";
+import { namesFor, type NameEntry } from "@/lib/profiles/server";
+import { rankBy, scoreSeason, whyLine, type CreatorBreakdown, type FirstBuy, type ScoreLaunch, type ScoreSwap, type ScoutBreakdown } from "./score";
+
+/**
+ * Season points, the server half: load one season's chain data, score it (score.ts), replace the season's rows.
+ * Runs hourly from the sync loop on ONE machine (session advisory lock) and on demand from /admin. Everything is
+ * recomputed from scratch each time, so a rule change or an admin flag applies to the whole season on the next run.
+ */
+
+export type Season = { id: number; slug: string; name: string; starts_at: string; ends_at: string; public: boolean };
+export type Board = "creator" | "scout";
+export type BoardRow = { rank: number; wallet: string; points: number; why: string };
+export type WalletPoints = { creator: number; scout: number; total: number; eligible: boolean; rank_creator: number | null; rank_scout: number | null; why_creator: string; why_scout: string; computed_at: string };
+
+/** Eligibility for points (and for the 6× holder weight): env-tunable, never hard-coded in the rules. */
+export function eligibilityConfig(): { minAgeDays: number; minFollowers: number } {
+ const n = (v: string | undefined, d: number) => (Number.isFinite(Number(v)) && v !== "" && v !== undefined ? Number(v) : d);
+ return { minAgeDays: n(process.env.POINTS_MIN_X_AGE_DAYS, 30), minFollowers: n(process.env.POINTS_MIN_X_FOLLOWERS, 20) };
+}
+
+/** The current season: the latest one that has started (a finished one stays as the final standings until the next). */
+export async function currentSeason(): Promise {
+ const db = maybeDb();
+ if (!db) return null;
+ const [s] = await db`SELECT id, slug, name, starts_at, ends_at, public FROM bb_seasons WHERE starts_at <= now() ORDER BY starts_at DESC LIMIT 1`;
+ return s ?? null;
+}
+
+// ── compute ──────────────────────────────────────────────────────────────────
+type LaunchRowRaw = { chain_id: number; token: string; launcher: string; recipients: { payout: string; bps: number }[]; block_number: bigint; block_time: string; lp_fee: number; quote: string; sqrt_price_x96: string | null };
+
+export async function computeSeason(db: Db, season: Season): Promise<{ wallets: number; eligible: number; ms: number }> {
+ const t0 = Date.now();
+ const start = new Date(season.starts_at).getTime();
+ const end = new Date(season.ends_at).getTime();
+ const now = Date.now();
+ const until = new Date(Math.min(now, end)).toISOString();
+
+ // the season's swaps decide which tokens are in scope
+ const swapRows = await db<{ chain_id: number; token: string; trader: string; is_buy: boolean; amount0: string; amount1: string; block_number: bigint; t: string }[]>`
+ SELECT chain_id, token, trader, is_buy, amount0, amount1, block_number, block_time AS t
+ FROM bb_launch_swaps WHERE block_time >= ${season.starts_at} AND block_time < ${until} AND trader IS NOT NULL`;
+ const keyOf = (cid: number, token: string) => `${chainKeyOf(cid) ?? cid}:${token}`;
+ const scope = new Map();
+ for (const s of swapRows) scope.set(keyOf(s.chain_id, s.token), { cid: s.chain_id, token: s.token });
+ const cids = [...scope.values()].map((x) => x.cid);
+ const toks = [...scope.values()].map((x) => x.token);
+
+ const [launchRows, firstRows, holderRows, boughtRows, linkedRows, profileRows, stateRows] = await Promise.all([
+ db`
+ SELECT l.chain_id, l.token, l.launcher, l.recipients, l.block_number, l.block_time, l.lp_fee, l.quote, l.sqrt_price_x96
+ FROM bb_launches l JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON l.chain_id = u.cid AND l.token = u.tok`,
+ db<{ chain_id: number; token: string; trader: string; block_number: bigint; t: string; amount0: string }[]>`
+ SELECT DISTINCT ON (s.chain_id, s.token, s.trader) s.chain_id, s.token, s.trader, s.block_number, s.block_time AS t, s.amount0
+ FROM bb_launch_swaps s JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON s.chain_id = u.cid AND s.token = u.tok
+ WHERE s.is_buy AND s.trader IS NOT NULL ORDER BY s.chain_id, s.token, s.trader, s.block_number, s.log_index`,
+ db<{ chain_id: number; token: string; holder: string; balance: string }[]>`
+ SELECT h.chain_id, h.token, h.holder, h.balance
+ FROM bb_token_holders h JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON h.chain_id = u.cid AND h.token = u.tok
+ WHERE h.balance > 0`,
+ db<{ chain_id: number; token: string; bought: string }[]>`
+ SELECT s.chain_id, s.token, sum(s.amount1)::text AS bought
+ FROM bb_launch_swaps s JOIN bb_launches l ON l.chain_id = s.chain_id AND l.token = s.token
+ JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON s.chain_id = u.cid AND s.token = u.tok
+ WHERE s.is_buy AND s.trader = l.launcher GROUP BY s.chain_id, s.token`,
+ db<{ chain_id: number; token: string; to_addr: string }[]>`
+ SELECT DISTINCT t.chain_id, t.token, t.to_addr
+ FROM bb_token_transfers t JOIN bb_launches l ON l.chain_id = t.chain_id AND l.token = t.token
+ JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON t.chain_id = u.cid AND t.token = u.tok
+ WHERE t.from_addr = l.launcher OR t.from_addr IN (SELECT lower(r->>'payout') FROM jsonb_array_elements(l.recipients) r)`,
+ db<{ wallet: string; x_status: string; x_account_created: string | null; x_followers: number | null; points_flag: string | null; hidden: boolean }[]>`
+ SELECT wallet, x_status, x_account_created, x_followers, points_flag, hidden FROM bb_profiles WHERE deleted_at IS NULL`,
+ db<{ chain_id: number; token: string; max_outside_holders: number }[]>`
+ SELECT chain_id, token, max_outside_holders FROM bb_points_token_state WHERE season_id = ${season.id}`,
+ ]);
+
+ const usd = await ethUsd();
+ const price = await quotePricer(usd);
+ const launches: ScoreLaunch[] = launchRows.map((l) => {
+ const chain = chainKeyOf(l.chain_id) as ChainKey;
+ const q = price(chain, l.quote);
+ const perToken = l.sqrt_price_x96 ? quotePerToken(BigInt(l.sqrt_price_x96), q.decimals) : 0;
+ return {
+ key: keyOf(l.chain_id, l.token),
+ launcher: l.launcher,
+ recipients: (Array.isArray(l.recipients) ? l.recipients : []).map((r) => String(r.payout).toLowerCase()),
+ launchBlock: Number(l.block_number),
+ launchTime: new Date(l.block_time).getTime(),
+ lpFee: Number(l.lp_fee),
+ quoteDecimals: q.decimals,
+ quoteUsd: q.usd,
+ tokenUsd: q.usd === null || !perToken ? null : perToken * q.usd,
+ };
+ });
+ const abs = (v: string) => (v.startsWith("-") ? BigInt(v.slice(1)) : BigInt(v));
+ const swaps: ScoreSwap[] = swapRows.map((s) => ({ key: keyOf(s.chain_id, s.token), trader: s.trader, isBuy: s.is_buy, quoteRaw: abs(s.amount0), tokenRaw: abs(s.amount1), block: Number(s.block_number), time: new Date(s.t).getTime() }));
+ const firstBuys: FirstBuy[] = firstRows.map((f) => ({ key: keyOf(f.chain_id, f.token), wallet: f.trader, block: Number(f.block_number), time: new Date(f.t).getTime(), quoteRaw: abs(f.amount0) }));
+ const linked = new Map>();
+ for (const r of linkedRows) {
+ const k = keyOf(r.chain_id, r.token);
+ const set = linked.get(k) ?? new Set();
+ set.add(r.to_addr);
+ linked.set(k, set);
+ }
+ const cfg = eligibilityConfig();
+ const eligible = new Set(profileRows.filter((p) => pointsEligible(p, now, cfg)).map((p) => p.wallet));
+ const flagged = new Set(profileRows.filter((p) => p.points_flag).map((p) => p.wallet));
+ const system = new Set([ZERO_ADDR, DEAD_ADDR]);
+ for (const c of CHAIN_KEYS) {
+ try {
+ for (const a of systemAddresses(c)) system.add(a);
+ } catch {
+ /* a chain without a deployment */
+ }
+ }
+
+ const result = scoreSeason({
+ seasonStart: start,
+ seasonEnd: end,
+ now,
+ launches,
+ swaps,
+ firstBuys,
+ holders: holderRows.map((h) => ({ key: keyOf(h.chain_id, h.token), wallet: h.holder, balanceRaw: BigInt(h.balance.split(".")[0]) })),
+ launcherBought: new Map(boughtRows.map((b) => [keyOf(b.chain_id, b.token), abs(b.bought.split(".")[0])])),
+ linked,
+ system,
+ eligible,
+ flagged,
+ maxOutsideBefore: new Map(stateRows.map((s) => [keyOf(s.chain_id, s.token), Number(s.max_outside_holders)])),
+ });
+
+ const scores = [...result.wallets.values()];
+ const rc = rankBy(scores, "creator", eligible);
+ const rs = rankBy(scores, "scout", eligible);
+ const rows = scores.map((s) => ({
+ wallet: s.wallet,
+ creator: s.creator,
+ scout: s.scout,
+ total: s.total,
+ eligible: eligible.has(s.wallet),
+ rank_creator: rc.get(s.wallet) ?? null,
+ rank_scout: rs.get(s.wallet) ?? null,
+ breakdown: { creator: s.creatorWhy, scout: s.scoutWhy },
+ }));
+ const states = [...result.maxOutside.entries()].map(([k, n]) => ({ cid: scope.get(k)?.cid ?? 0, token: scope.get(k)?.token ?? "", n })).filter((x) => x.cid && x.n > 0);
+
+ await db.begin(async (tx) => {
+ const t = tx as unknown as Db;
+ await t`DELETE FROM bb_points WHERE season_id = ${season.id}`;
+ for (let i = 0; i < rows.length; i += 1000) {
+ const part = rows.slice(i, i + 1000);
+ await t`
+ INSERT INTO bb_points (season_id, wallet, creator, scout, total, eligible, rank_creator, rank_scout, breakdown, computed_at)
+ SELECT ${season.id}, u.w, u.c, u.s, u.tot, u.e = 't', NULLIF(u.rc, '')::int, NULLIF(u.rs, '')::int, u.b::jsonb, now()
+ FROM unnest(${part.map((r) => r.wallet)}::text[], ${part.map((r) => r.creator)}::int[], ${part.map((r) => r.scout)}::int[], ${part.map((r) => r.total)}::int[],
+ ${part.map((r) => (r.eligible ? "t" : "f"))}::text[], ${part.map((r) => String(r.rank_creator ?? ""))}::text[], ${part.map((r) => String(r.rank_scout ?? ""))}::text[],
+ ${part.map((r) => JSON.stringify(r.breakdown))}::text[])
+ AS u(w, c, s, tot, e, rc, rs, b)`; // booleans and nullable ints travel as text: the driver does not type those arrays
+ }
+ if (states.length) {
+ await t`
+ INSERT INTO bb_points_token_state (season_id, chain_id, token, max_outside_holders)
+ SELECT ${season.id}, u.cid, u.tok, u.n FROM unnest(${states.map((x) => x.cid)}::int[], ${states.map((x) => x.token)}::text[], ${states.map((x) => x.n)}::int[]) AS u(cid, tok, n)
+ ON CONFLICT (season_id, chain_id, token) DO UPDATE SET max_outside_holders = GREATEST(bb_points_token_state.max_outside_holders, EXCLUDED.max_outside_holders)`;
+ }
+ });
+ return { wallets: rows.length, eligible: rows.filter((r) => r.eligible).length, ms: Date.now() - t0 };
+}
+
+let lastRun = 0;
+const EVERY_MS = 60 * 60_000;
+
+/** The hourly run, from the sync loop: one machine at a time (session advisory lock), at most hourly per season. */
+export async function computePointsIfDue(force = false): Promise<{ wallets: number; eligible: number; ms: number } | null> {
+ const pool = maybeDb();
+ if (!pool) return null;
+ if (!force && Date.now() - lastRun < 5 * 60_000) return null; // cheap check before touching the db
+ lastRun = Date.now();
+ const season = await currentSeason();
+ if (!season) return null;
+ if (!force) {
+ const [r] = await pool<{ at: string | null }[]>`SELECT max(computed_at) AS at FROM bb_points WHERE season_id = ${season.id}`;
+ // a finished season is computed once more after its end, then left as the final standings
+ if (r?.at && (Date.now() - new Date(r.at).getTime() < EVERY_MS || new Date(r.at).getTime() > new Date(season.ends_at).getTime())) return null;
+ }
+ // the session lock lives on one reserved connection; the work itself (reads, the replace transaction) uses the pool
+ const reserved = await pool.reserve();
+ const db = reserved as unknown as Db;
+ try {
+ const [{ ok }] = await db<{ ok: boolean }[]>`SELECT pg_try_advisory_lock(hashtext('points-compute')) AS ok`;
+ if (!ok) return null;
+ try {
+ const r = await computeSeason(pool, season);
+ console.log(`[points] ${season.slug}: ${r.wallets} wallets (${r.eligible} eligible) in ${r.ms}ms`);
+ return r;
+ } finally {
+ await db`SELECT pg_advisory_unlock(hashtext('points-compute'))`.catch(() => {});
+ }
+ } finally {
+ reserved.release();
+ }
+}
+
+// ── read ─────────────────────────────────────────────────────────────────────
+type PointsRaw = { wallet: string; creator: number; scout: number; total: number; eligible: boolean; rank_creator: number | null; rank_scout: number | null; breakdown: { creator: CreatorBreakdown; scout: ScoutBreakdown }; computed_at: string };
+
+export async function boardRows(seasonId: number, board: Board, limit = 100): Promise<{ rows: BoardRow[]; names: Record }> {
+ const db = maybeDb();
+ if (!db) return { rows: [], names: {} };
+ const n = Math.min(200, Math.max(1, limit));
+ const raw =
+ board === "creator"
+ ? await db`SELECT wallet, creator, scout, total, eligible, rank_creator, rank_scout, breakdown, computed_at FROM bb_points WHERE season_id = ${seasonId} AND rank_creator IS NOT NULL ORDER BY rank_creator, wallet LIMIT ${n}`
+ : await db`SELECT wallet, creator, scout, total, eligible, rank_creator, rank_scout, breakdown, computed_at FROM bb_points WHERE season_id = ${seasonId} AND rank_scout IS NOT NULL ORDER BY rank_scout, wallet LIMIT ${n}`;
+ const rows = raw.map((r) => ({ rank: (board === "creator" ? r.rank_creator : r.rank_scout) ?? 0, wallet: r.wallet, points: board === "creator" ? r.creator : r.scout, why: whyLine(board, { creatorWhy: r.breakdown.creator, scoutWhy: r.breakdown.scout }) }));
+ return { rows, names: await namesFor(rows.map((r) => r.wallet)) };
+}
+
+export async function walletPoints(seasonId: number, wallet: string): Promise {
+ const db = maybeDb();
+ if (!db || !isAddress(wallet)) return null;
+ const [r] = await db`SELECT wallet, creator, scout, total, eligible, rank_creator, rank_scout, breakdown, computed_at FROM bb_points WHERE season_id = ${seasonId} AND wallet = ${wallet.toLowerCase()}`;
+ if (!r) return null;
+ const why = { creatorWhy: r.breakdown.creator, scoutWhy: r.breakdown.scout };
+ return { creator: r.creator, scout: r.scout, total: r.total, eligible: r.eligible, rank_creator: r.rank_creator, rank_scout: r.rank_scout, why_creator: whyLine("creator", why), why_scout: whyLine("scout", why), computed_at: r.computed_at };
+}
+
+// ── admin ────────────────────────────────────────────────────────────────────
+export async function startSeason(days = 28): Promise {
+ const db = maybeDb()!;
+ const [{ n }] = await db<{ n: number }[]>`SELECT count(*)::int AS n FROM bb_seasons`;
+ const [s] = await db`
+ INSERT INTO bb_seasons (slug, name, starts_at, ends_at, public) VALUES (${`s${n + 1}`}, ${`Season ${n + 1}`}, now(), now() + make_interval(days => ${days}), false)
+ RETURNING id, slug, name, starts_at, ends_at, public`;
+ return s;
+}
+
+export async function setSeasonPublic(id: number, value: boolean): Promise {
+ const db = maybeDb()!;
+ await db`UPDATE bb_seasons SET public = ${value} WHERE id = ${id}`;
+}
+
+export async function endSeasonNow(id: number): Promise {
+ const db = maybeDb()!;
+ await db`UPDATE bb_seasons SET ends_at = LEAST(ends_at, now()) WHERE id = ${id}`;
+}
+
+/** The shadow view for admins: top wallets on each board, eligible or not, with why lines (the trial-week review). */
+export async function previewBoards(seasonId: number, limit = 50): Promise<{ creator: (BoardRow & { eligible: boolean })[]; scout: (BoardRow & { eligible: boolean })[]; names: Record; computed_at: string | null; wallets: number; eligible: number }> {
+ const db = maybeDb();
+ if (!db) return { creator: [], scout: [], names: {}, computed_at: null, wallets: 0, eligible: 0 };
+ const [c, s, [meta]] = await Promise.all([
+ db`SELECT wallet, creator, scout, total, eligible, rank_creator, rank_scout, breakdown, computed_at FROM bb_points WHERE season_id = ${seasonId} AND creator > 0 ORDER BY creator DESC, wallet LIMIT ${limit}`,
+ db`SELECT wallet, creator, scout, total, eligible, rank_creator, rank_scout, breakdown, computed_at FROM bb_points WHERE season_id = ${seasonId} AND scout > 0 ORDER BY scout DESC, wallet LIMIT ${limit}`,
+ db<{ at: string | null; n: number; e: number }[]>`SELECT max(computed_at) AS at, count(*)::int AS n, count(*) FILTER (WHERE eligible)::int AS e FROM bb_points WHERE season_id = ${seasonId}`,
+ ]);
+ const row = (board: Board) => (r: PointsRaw, i: number) => ({ rank: i + 1, wallet: r.wallet, points: board === "creator" ? r.creator : r.scout, eligible: r.eligible, why: whyLine(board, { creatorWhy: r.breakdown.creator, scoutWhy: r.breakdown.scout }) });
+ const creator = c.map(row("creator"));
+ const scout = s.map(row("scout"));
+ return { creator, scout, names: await namesFor([...creator, ...scout].map((r) => r.wallet)), computed_at: meta?.at ?? null, wallets: meta?.n ?? 0, eligible: meta?.e ?? 0 };
+}
diff --git a/app/src/lib/profiles/server.ts b/app/src/lib/profiles/server.ts
index 21e3eefb..a9538f07 100644
--- a/app/src/lib/profiles/server.ts
+++ b/app/src/lib/profiles/server.ts
@@ -265,7 +265,16 @@ async function consumeNonce(db: Db, nonce: string, wallet: string): Promise string): Promise<{ ok: true; wallet: string } | Fail> {
+ const db = maybeDb();
+ if (!db) return fail("db unconfigured", 503);
+ if (typeof s.wallet !== "string" || !adminWallets().has(s.wallet.toLowerCase())) return fail("not an admin", 403);
+ const a = await admit(db, s, message);
+ return a.ok ? { ok: true, wallet: a.wallet } : a;
+}
/**
* Shared front of every signed write: shapes, freshness, signature, then (only then) the rate-limit bucket and the
From 0dfc8cdf74b606c0fded78c2b3f94eb78ce50f76 Mon Sep 17 00:00:00 2001
From: Kevin Codex
Date: Wed, 7 Oct 2026 16:25:14 +0800
Subject: [PATCH 02/13] fix(points): review round 1 (real buyers and holders,
priced tokens only, fee and hold caps)
The review showed the first rules could be farmed for gas: an unpriced
quote skipped every dollar floor, 1-wei transfers counted as holders and
unlocked every threshold, wash trades earned fee points while the fees
came back to the farmer, "still held" meant any balance, and a price
outage could replace the board. The scoring now rests on two definitions:
- real buyer: first buy >= $5, outside the sniper window (launch block +
3 and the first 12 seconds, so fast chains are covered), not the
launcher / a recipient / a transfer-fed wallet / a flagged wallet
- real holder: a real buyer still holding >= $5 (>= $1 if eligible)
and on top of them:
- only priced tokens score; a run with a listed quote unpriced (or no
ETH price) writes nothing and the last board stays
- creator fee points only from eligible traders, at most 50 per trader
per creator per day; trades under $5 never count
- each holder counts once per creator (their best token)
- "still held" = at least half of that buy; scout fee points at most
200 a day across all tokens; early slots only for real buyers, ordered
by block and log index
- dump = sold during the season and now keeps under half of what was
bought (selling fee tokens no longer counts); "alive" removed
- flags apply to deleted profiles too; thresholds are recomputed every
run (no ratchet table)
Server: reads run one after another on the reserved lock connection, the
replace on the pool, and the loop no longer waits for a compute.
bb_seasons.computed_at throttles (a season with no points no longer
recomputes every few minutes); an ended season gets one final compute
and admin recompute refuses after it; starting a season finalizes the
previous one first; publishing starts the season's clock for everyone.
Eligibility thresholds live in RULES (the rules page is static). /me
says what stands between a wallet and the board; ended seasons show
final standings; the profile page fails soft on points errors.
---
app/db/schema.sql | 13 +-
app/src/app/api/points/admin/route.ts | 19 +-
app/src/app/api/points/route.ts | 13 +-
app/src/app/leaderboard/page.tsx | 16 +-
app/src/app/rules/page.tsx | 11 +-
app/src/app/u/[username]/page.tsx | 5 +-
app/src/components/points/PointsAdmin.tsx | 5 +-
app/src/components/points/SeasonCard.tsx | 27 +-
app/src/components/points/YourStanding.tsx | 2 +-
app/src/components/points/usePoints.ts | 3 +-
app/src/lib/launchpad/loop.ts | 5 +-
app/src/lib/launchpad/queries.ts | 4 +-
app/src/lib/points/score.test.ts | 229 +++++++-------
app/src/lib/points/score.ts | 331 +++++++++++----------
app/src/lib/points/server.ts | 217 ++++++++------
15 files changed, 515 insertions(+), 385 deletions(-)
diff --git a/app/db/schema.sql b/app/db/schema.sql
index cafb51ad..83f2752e 100644
--- a/app/db/schema.sql
+++ b/app/db/schema.sql
@@ -396,8 +396,8 @@ ALTER TABLE bb_launch_swaps ADD COLUMN IF NOT EXISTS tx_from text;
-- ── points, Season 1 (2026-10-07) ──────────────────────────────────────────
-- Reputation points for launching and trading, recomputed from scratch every hour from the chain index
--- (lib/points/score.ts). No cash value, not a token. A season is started, published and ended by an admin; until it
--- is public the boards are visible to admins only (the shadow run that tunes the rules on real data).
+-- (lib/points/score.ts). No cash value, not a token. An admin starts a season (a shadow run only admins see, to tune the
+-- rules on real data); publishing starts the season's clock for everyone; an ended season keeps its final standings.
CREATE TABLE IF NOT EXISTS bb_seasons (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
slug text NOT NULL, -- s1, s2…
@@ -405,6 +405,7 @@ CREATE TABLE IF NOT EXISTS bb_seasons (
starts_at timestamptz NOT NULL,
ends_at timestamptz NOT NULL,
public boolean NOT NULL DEFAULT false,
+ computed_at timestamptz, -- last successful compute (also with zero rows)
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS bb_seasons_slug_uq ON bb_seasons (slug);
@@ -424,11 +425,3 @@ CREATE TABLE IF NOT EXISTS bb_points (
);
CREATE INDEX IF NOT EXISTS bb_points_creator_idx ON bb_points (season_id, rank_creator) WHERE rank_creator IS NOT NULL;
CREATE INDEX IF NOT EXISTS bb_points_scout_idx ON bb_points (season_id, rank_scout) WHERE rank_scout IS NOT NULL;
--- The most outside holders a token has had this season ("reached 50" survives a later drop).
-CREATE TABLE IF NOT EXISTS bb_points_token_state (
- season_id integer NOT NULL,
- chain_id integer NOT NULL,
- token text NOT NULL,
- max_outside_holders integer NOT NULL DEFAULT 0,
- PRIMARY KEY (season_id, chain_id, token)
-);
diff --git a/app/src/app/api/points/admin/route.ts b/app/src/app/api/points/admin/route.ts
index b508563d..f533a5de 100644
--- a/app/src/app/api/points/admin/route.ts
+++ b/app/src/app/api/points/admin/route.ts
@@ -3,13 +3,14 @@ import { rateLimited } from "@/lib/launchpad/editServer";
import { clientIp, readJson } from "@/lib/profiles/http";
import { admitAdmin } from "@/lib/profiles/server";
import { buildPointsAdminMessage, isPointsAdminAction } from "@/lib/points/auth";
-import { computePointsIfDue, currentSeason, endSeasonNow, previewBoards, setSeasonPublic, startSeason } from "@/lib/points/server";
+import { currentSeason, endSeasonNow, finalizeIfEnded, hideSeason, previewBoards, publishSeason, recomputeNow, seasonEnded, startSeason } from "@/lib/points/server";
export const dynamic = "force-dynamic";
/**
* POST {action, days?, chain, wallet, nonce, ts, signature} → one admin-signed points action:
- * preview (the shadow boards), start (a new season, not public), publish / unpublish, end (now), recompute (now).
+ * preview (the shadow boards), start (a new season as a shadow run; the previous one's final standings are computed
+ * first), publish (the season starts now for everyone) / unpublish, end (now), recompute (now; not after the final).
*/
export async function POST(req: Request) {
if (rateLimited(`points-admin:ip:${clientIp(req)}`, 30)) return NextResponse.json({ error: "slow down" }, { status: 429 });
@@ -23,23 +24,29 @@ export async function POST(req: Request) {
const season = await currentSeason();
switch (action) {
case "start": {
- if (season && new Date(season.ends_at).getTime() > Date.now()) return NextResponse.json({ error: `${season.name} is still running` }, { status: 409 });
+ if (season && !seasonEnded(season)) return NextResponse.json({ error: `${season.name} is still running` }, { status: 409 });
+ if (season) await finalizeIfEnded(season);
const days = Math.min(90, Math.max(1, Math.trunc(Number(b.days) || 28)));
const s = await startSeason(days);
- void computePointsIfDue(true).catch(() => {});
+ void recomputeNow().catch(() => {});
return NextResponse.json({ ok: true, season: s });
}
case "publish":
+ if (!season) return NextResponse.json({ error: "no season" }, { status: 404 });
+ await publishSeason(season.id);
+ void recomputeNow().catch(() => {});
+ return NextResponse.json({ ok: true });
case "unpublish":
if (!season) return NextResponse.json({ error: "no season" }, { status: 404 });
- await setSeasonPublic(season.id, action === "publish");
+ await hideSeason(season.id);
return NextResponse.json({ ok: true });
case "end":
if (!season) return NextResponse.json({ error: "no season" }, { status: 404 });
await endSeasonNow(season.id);
return NextResponse.json({ ok: true });
case "recompute": {
- const r = await computePointsIfDue(true);
+ const r = await recomputeNow();
+ if (r && "error" in r) return NextResponse.json({ error: r.error }, { status: 409 });
return NextResponse.json({ ok: true, result: r });
}
case "preview":
diff --git a/app/src/app/api/points/route.ts b/app/src/app/api/points/route.ts
index 8669258d..99d85e81 100644
--- a/app/src/app/api/points/route.ts
+++ b/app/src/app/api/points/route.ts
@@ -1,18 +1,21 @@
import { NextResponse } from "next/server";
import { isAddress } from "viem";
import { memo } from "@/lib/launchpad/memo";
-import { currentSeason, walletPoints } from "@/lib/points/server";
+import { currentSeason, seasonEnded, walletPoints, walletReason } from "@/lib/points/server";
export const dynamic = "force-dynamic";
-/** GET /api/points[?wallet=0x…] → the public season (null while it is not public) and, with a wallet, its points. */
+/**
+ * GET /api/points[?wallet=0x…] → the public season (null while it is not public) and, with a wallet, its points and
+ * what stands between it and the board (reason null = it is eligible).
+ */
export async function GET(req: Request) {
const w = (new URL(req.url).searchParams.get("wallet") ?? "").toLowerCase();
try {
const season = await memo("points:season", 10_000, currentSeason);
- if (!season?.public) return NextResponse.json({ season: null, me: null }, { headers: { "cache-control": "no-store" } });
- const me = isAddress(w) ? await memo(`points:me:${season.id}:${w}`, 10_000, () => walletPoints(season.id, w)) : null;
- return NextResponse.json({ season: { name: season.name, starts_at: season.starts_at, ends_at: season.ends_at }, me }, { headers: { "cache-control": "no-store" } });
+ if (!season?.public) return NextResponse.json({ season: null, me: null, reason: null }, { headers: { "cache-control": "no-store" } });
+ const [me, reason] = isAddress(w) ? await Promise.all([memo(`points:me:${season.id}:${w}`, 10_000, () => walletPoints(season.id, w)), memo(`points:why:${w}`, 10_000, () => walletReason(w))]) : [null, null];
+ return NextResponse.json({ season: { name: season.name, starts_at: season.starts_at, ends_at: season.ends_at, ended: seasonEnded(season) }, me, reason }, { headers: { "cache-control": "no-store" } });
} catch (err) {
console.error("[points] read failed:", err instanceof Error ? err.message : err);
return NextResponse.json({ error: "could not load points" }, { status: 502 });
diff --git a/app/src/app/leaderboard/page.tsx b/app/src/app/leaderboard/page.tsx
index 396339a4..050a4078 100644
--- a/app/src/app/leaderboard/page.tsx
+++ b/app/src/app/leaderboard/page.tsx
@@ -6,7 +6,7 @@ import NamesProvider from "@/components/profile/NamesProvider";
import { WhoAvatar, WhoName } from "@/components/profile/Who";
import YourStanding from "@/components/points/YourStanding";
import { memo } from "@/lib/launchpad/memo";
-import { boardRows, currentSeason, type Board } from "@/lib/points/server";
+import { boardRows, currentSeason, seasonEnded, type Board } from "@/lib/points/server";
import { pageMetadata } from "@/lib/seo";
export const dynamic = "force-dynamic";
@@ -39,6 +39,7 @@ export default async function LeaderboardPage({ searchParams }: { searchParams:
const data = await memo(`points:board:${season.id}:${board}`, 30_000, () => boardRows(season.id, board));
const left = daysLeft(season.ends_at);
+ const ended = seasonEnded(season);
const tab = (b: Board, label: string) => (
{label}
);
@@ -55,8 +56,17 @@ export default async function LeaderboardPage({ searchParams }: { searchParams:
Real buyers and early finds, read from the chain every hour. Points are reputation: no cash value, not a token. How points work
diff --git a/app/src/app/rules/page.tsx b/app/src/app/rules/page.tsx
index 6e8e9915..8f5478ba 100644
--- a/app/src/app/rules/page.tsx
+++ b/app/src/app/rules/page.tsx
@@ -11,9 +11,6 @@ import { BRAND_GITHUB } from "@/lib/brand";
import LaunchMachine from "@/components/launchpad/LaunchMachine";
import { ChainLogo } from "@/components/launchpad/ChainLogo";
import { RULES } from "@/lib/points/score";
-import { eligibilityConfig } from "@/lib/points/server";
-
-const POINTS_X = eligibilityConfig();
export const metadata: Metadata = pageMetadata({ path: "/rules", title: "How it works", description: "What a launch does on-chain, what it costs (gas), and what can never happen to your liquidity." });
@@ -120,10 +117,10 @@ export default function RulesPage() {
Season points
Points for real buyers. Nothing for noise.
See the leaderboard
Points measure reputation on openlaunch. They have no cash value, are not a token and not a promise of one, cannot be transferred or sold, and the rules can change between seasons. Wallets that game the system lose their points. Everything is computed from the chain every hour; the rules below are the whole of it.
- How do creators earn?
On any token you launched, only what happens during the season counts. Each outside buyer whose first buy was this season and who still holds a day later earns you {RULES.verifiedHolder} points if their profile is verified on X, or {RULES.holder} if not (at least ${RULES.verifiedHolderMinUsd} or ${RULES.holderMinUsd} held). Still holding at day 7 adds half again.
Trading fees from outside trades earn {RULES.creatorFeePointsPerUsd} points per $1, whoever the fee goes to. A token that is a week old, has {RULES.aliveMinHolders}+ outside holders and an outside trade in the last day earns {RULES.alive} once.
Of the tokens you launch on one day, only your best {RULES.bestTokensPerDay} count. A token scores nothing if, during the season, you sell half or more of what you bought of it.
- How do traders earn?
Be one of the first {RULES.earlyFirstBuyers} outside buyers of a token that goes on to reach {RULES.earlyHoldersTarget} outside holders: {RULES.early} points, and {RULES.earlyStillHolding} more while you still hold it.
Every token you buy (${RULES.minTradeUsd}+) and still hold a day later: {RULES.hold} points, up to {RULES.holdTokensPerDay} tokens a day. Fees you pay on other people's tokens with {RULES.scoutFeeMinHolders}+ outside holders: {RULES.scoutFeePointsPerUsd} points per $1, up to {RULES.scoutFeeCapPerTokenDay} per token a day.
- What never counts?
Launching by itself, trading your own token, trades under ${RULES.minTradeUsd}, buys in the launch block and the next {RULES.sniperBlocks} blocks, wallets that got a token by transfer from its launcher or fee recipient, and the launcher and fee recipients themselves.
- Who appears on the leaderboard?
Profiles verified with one post on X from an account at least {POINTS_X.minAgeDays} days old with {POINTS_X.minFollowers} or more followers. Everyone else can see the points waiting for them on their dashboard; they unlock, for the whole season so far, the moment the profile is verified. The post itself earns nothing.
+ What counts as a real buyer?
A wallet whose first buy of a token was ${RULES.minTradeUsd} or more, made after the launch block, the next {RULES.sniperBlocks} blocks and the first {RULES.sniperMs / 1000} seconds. Never the launcher, a fee recipient, a wallet that got the token by transfer from either, or a wallet kept off points. A real holder is a real buyer still holding ${RULES.holderMinUsd} or more (${RULES.verifiedHolderMinUsd} for a verified profile). Only tokens priced in a quote we can price count.
+ How do creators earn?
On any token you launched, only what happens during the season counts. Each real holder who first bought this season and still holds a day later earns you {RULES.verifiedHolder} points if their profile qualifies (below), or {RULES.holder} if not; a week later, half again. Each holder counts once for you, on whichever of your tokens they are worth most.
Trading fees from verified traders' trades earn {RULES.creatorFeePointsPerUsd} points per $1, up to {RULES.creatorFeeCapPerTraderDay} from one trader a day. Of the tokens you launch on one day, only your best {RULES.bestTokensPerDay} count. A token scores nothing if you sold during the season and now keep less than half of what you bought of it.
+ How do traders earn?
Be one of the first {RULES.earlyFirstBuyers} real buyers of a token that now has {RULES.earlyHoldersTarget} real holders: {RULES.early} points, and {RULES.earlyStillHolding} more while you still hold half of that first buy.
Every token you first buy this season and still half-hold a day later: {RULES.hold} points, up to {RULES.holdTokensPerDay} tokens a day. Fees you pay on other people's tokens with {RULES.scoutFeeMinHolders}+ real holders: {RULES.scoutFeePointsPerUsd} points per $1, up to {RULES.scoutFeeCapPerDay} a day. Trades under ${RULES.minTradeUsd} earn nothing.
+ Who appears on the leaderboard?
Profiles verified with one post on X from an account at least {RULES.eligibleMinAgeDays} days old with {RULES.eligibleMinFollowers} or more followers. Everyone else can see the points waiting for them on their dashboard; they unlock, for the whole season so far, within the hour after the profile qualifies. The post itself earns nothing.
diff --git a/app/src/app/u/[username]/page.tsx b/app/src/app/u/[username]/page.tsx
index 45397abd..b280b23b 100644
--- a/app/src/app/u/[username]/page.tsx
+++ b/app/src/app/u/[username]/page.tsx
@@ -62,8 +62,9 @@ export default async function ProfilePage({ params }: { params: Promise<{ userna
// the owner's name comes from the profile this page just read, not from a separately cached lookup
const ownName = { u: p.username, d: p.display_name, a: p.avatar_url, v: p.x_state === "verified" };
// season points, once the season is public, for a wallet that qualifies
- const season = await memo("points:season", 10_000, currentSeason);
- const pts = season?.public ? await memo(`points:me:${season.id}:${p.wallet}`, 10_000, () => walletPoints(season.id, p.wallet)) : null;
+ // fails soft: a points hiccup never takes the profile page down
+ const season = await memo("points:season", 10_000, currentSeason).catch(() => null);
+ const pts = season?.public ? await memo(`points:me:${season.id}:${p.wallet}`, 10_000, () => walletPoints(season.id, p.wallet)).catch(() => null) : null;
const showPts = Boolean(season && pts && pts.eligible && pts.total > 0);
const now = nowMs();
const joined = new Date(p.created_at).toLocaleDateString("en-US", { month: "short", year: "numeric", timeZone: "UTC" });
diff --git a/app/src/components/points/PointsAdmin.tsx b/app/src/components/points/PointsAdmin.tsx
index 1e92a241..e400aeb7 100644
--- a/app/src/components/points/PointsAdmin.tsx
+++ b/app/src/components/points/PointsAdmin.tsx
@@ -98,11 +98,12 @@ export default function PointsAdmin() {
{s.public ? public : shadow (admins only)} · {data.wallets ?? 0} wallets with points, {data.eligible ?? 0} eligible · computed {data.computed_at ? new Date(data.computed_at).toLocaleTimeString() : "not yet"}
) : (
-
No season yet. Starting one begins the shadow run: points are computed hourly but only admins see them until you publish.
+
No season yet. Starting one begins the shadow run: points are computed hourly but only admins see them. Publishing then starts the season for everyone, with a fresh 28-day clock (the shadow run's points are only for tuning).
diff --git a/app/src/components/points/SeasonCard.tsx b/app/src/components/points/SeasonCard.tsx
index 96dc3b17..5ee37ca1 100644
--- a/app/src/components/points/SeasonCard.tsx
+++ b/app/src/components/points/SeasonCard.tsx
@@ -2,11 +2,22 @@
import Link from "next/link";
import { ArrowRight, Trophy } from "lucide-react";
+import { RULES, type NotEligibleReason } from "@/lib/points/score";
import { usePoints } from "./usePoints";
+/** The one step that puts a wallet on the board, by what stands in the way. */
+const NEXT_STEP: Record = {
+ no_profile: "Create your profile and verify it with one post on X",
+ not_verified: "Verify your profile with one post on X",
+ account_too_new: `Your X account needs to be at least ${RULES.eligibleMinAgeDays} days old`,
+ few_followers: `Your X account needs at least ${RULES.eligibleMinFollowers} followers`,
+ hidden: "Your profile is hidden by moderators",
+ kept_off: "This wallet is kept off the boards",
+};
+
/**
- * /me: this wallet's season points. Eligible → points and ranks. Not yet → the points waiting for it, and the one
- * step that unlocks them (a verified profile). Nothing at all while no season is public.
+ * /me: this wallet's season points. Eligible → points and ranks. Not yet → the points waiting for it and the one
+ * step that unlocks them. An ended season shows its final standings. Nothing while no season is public.
*/
export default function SeasonCard({ address }: { address: string }) {
const p = usePoints(address);
@@ -20,19 +31,25 @@ export default function SeasonCard({ address }: { address: string }) {
{why ?
{me.total.toLocaleString("en-US")} points are waiting for you. Verify your profile with one post on X (an account at least a month old) and they unlock for the whole season so far.
: null}
+ {waiting ? (
+
+ {me.total.toLocaleString("en-US")} points are waiting for you. {NEXT_STEP[p.reason as NotEligibleReason]}, and they unlock for the whole season so far within the hour.
+
No points yet this season. Launch something people hold, or find tokens early and hold them. How points work
+
{p.season.ended ? "No points this season." : <>No points yet this season. Launch something people hold, or find tokens early and hold them.{p.reason && p.reason !== "kept_off" && p.reason !== "hidden" ? <> {NEXT_STEP[p.reason]} so they count.> : null}>} How points work
{me.eligible ? (rank ? <>Rank #{rank}> : "Not ranked on this board yet") : <>Your points are waiting. Verify your profile with X to unlock them.>}{why ? · {why} : null}
+
{me.eligible ? (rank ? <>Rank #{rank}> : "Not ranked on this board yet") : <>Your points are waiting. See what unlocks them.>}{why ? · {why} : null}
{points.toLocaleString("en-US")}
diff --git a/app/src/components/points/usePoints.ts b/app/src/components/points/usePoints.ts
index 5ffb622d..4e89f5e5 100644
--- a/app/src/components/points/usePoints.ts
+++ b/app/src/components/points/usePoints.ts
@@ -2,8 +2,9 @@
import { useEffect, useState } from "react";
import type { WalletPoints } from "@/lib/points/server";
+import type { NotEligibleReason } from "@/lib/points/score";
-export type PointsState = { season: { name: string; starts_at: string; ends_at: string } | null; me: WalletPoints | null; at: number } | null;
+export type PointsState = { season: { name: string; starts_at: string; ends_at: string; ended: boolean } | null; me: WalletPoints | null; reason: NotEligibleReason | null; at: number } | null;
/** The public season and this wallet's points (null season while the season is not public). */
export function usePoints(wallet: string | undefined): PointsState {
diff --git a/app/src/lib/launchpad/loop.ts b/app/src/lib/launchpad/loop.ts
index 0af84203..a1a02d66 100644
--- a/app/src/lib/launchpad/loop.ts
+++ b/app/src/lib/launchpad/loop.ts
@@ -30,8 +30,9 @@ export function startLaunchSyncLoop(): boolean {
}
// verified X posts are re-read about weekly, a small batch every ten minutes (self-throttled)
await recheckProfiles().catch((err) => console.warn("[profiles] recheck:", err instanceof Error ? err.message : err));
- // season points: recomputed hourly from the index, one machine at a time (self-throttled)
- await computePointsIfDue().catch((err) => console.warn("[points] compute:", err instanceof Error ? err.message : err));
+ // season points: recomputed hourly from the index, one machine at a time (self-throttled); not awaited, so a
+ // long compute never holds up indexing
+ void computePointsIfDue().catch((err) => console.warn("[points] compute:", err instanceof Error ? err.message : err));
};
const timer = setInterval(() => void tick(), interval);
timer.unref();
diff --git a/app/src/lib/launchpad/queries.ts b/app/src/lib/launchpad/queries.ts
index 95578d9f..7afb7d6b 100644
--- a/app/src/lib/launchpad/queries.ts
+++ b/app/src/lib/launchpad/queries.ts
@@ -789,10 +789,10 @@ export async function getTrending(ethUsd: number | null = null): Promise { decimals: number; usd: number | null }> {
+export async function quotePricer(ethUsd: number | null): Promise<(chain: ChainKey, quote: string) => { key: string; decimals: number; usd: number | null }> {
await withStocks();
return (chain, quote) => {
const q = quoteInfo(chain, quote);
- return { decimals: q.decimals, usd: quoteUsd(q, ethUsd) };
+ return { key: q.key, decimals: q.decimals, usd: quoteUsd(q, ethUsd) };
};
}
diff --git a/app/src/lib/points/score.test.ts b/app/src/lib/points/score.test.ts
index 8502e1a2..3ab5b30e 100644
--- a/app/src/lib/points/score.test.ts
+++ b/app/src/lib/points/score.test.ts
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { RULES, rankBy, scoreSeason, whyLine, type FirstBuy, type ScoreInput, type ScoreLaunch, type ScoreSwap } from "./score.ts";
+import { RULES, isEligible, notEligibleReason, rankBy, scoreSeason, whyLine, type FirstBuy, type ScoreInput, type ScoreLaunch, type ScoreSwap } from "./score.ts";
const DAY = 86_400_000;
const START = Date.UTC(2026, 9, 1);
@@ -8,153 +8,178 @@ const NOW = START + 10 * DAY;
const CREATOR = "0xc0";
const RECIP = "0xfe";
const E18 = 10n ** 18n;
-const eth = (n: number) => BigInt(Math.round(n * 1e6)) * 10n ** 12n; // n ETH in wei
+const eth = (n: number) => BigInt(Math.round(n * 1e9)) * 10n ** 9n; // n ETH in wei
const w = (i: number) => `0x${i.toString(16).padStart(4, "0")}`;
-// a token launched before the season: quote ETH at $2,000, 1% fee, token price $0.001
+// launched before the season: ETH at $2,000, 1% fee, token at $0.001 (so 0.0025 ETH = $5 buys 5,000 tokens)
const L: ScoreLaunch = { key: "base:0xt1", launcher: CREATOR, recipients: [RECIP], launchBlock: 100, launchTime: START - 30 * DAY, lpFee: 10_000, quoteDecimals: 18, quoteUsd: 2000, tokenUsd: 0.001 };
+const tokensFor = (ethIn: number) => BigInt(Math.round((ethIn * 2000) / 0.001)) * E18;
function input(over: Partial = {}): ScoreInput {
- return {
- seasonStart: START,
- seasonEnd: START + 28 * DAY,
- now: NOW,
- launches: [L],
- swaps: [],
- firstBuys: [],
- holders: [],
- launcherBought: new Map(),
- linked: new Map(),
- system: new Set(["0xpool"]),
- eligible: new Set(),
- flagged: new Set(),
- maxOutsideBefore: new Map(),
- ...over,
- };
+ return { seasonStart: START, seasonEnd: START + 28 * DAY, now: NOW, launches: [L], swaps: [], firstBuys: [], holders: [], launcherBought: new Map(), linked: new Map(), system: new Set(["0xpool"]), eligible: new Set(), flagged: new Set(), ...over };
}
-/** a buyer who bought at `t`, spent `ethIn`, and holds `tokens` whole tokens now */
-function buyer(wallet: string, t: number, ethIn: number, tokens: number, block = 1000) {
- const s: ScoreSwap = { key: L.key, trader: wallet, isBuy: true, quoteRaw: eth(ethIn), tokenRaw: BigInt(tokens) * E18, block, time: t };
- const f: FirstBuy = { key: L.key, wallet, block, time: t, quoteRaw: eth(ethIn) };
- return { swap: s, first: f, holder: { key: L.key, wallet, balanceRaw: BigInt(tokens) * E18 } };
+let li = 0;
+/** a wallet that bought `ethIn` at `t` and holds `keep` of it now */
+function buyer(wallet: string, t: number, ethIn: number, keep = 1, launch: ScoreLaunch = L, block = 1000 + li) {
+ li++;
+ const tokenRaw = tokensFor(ethIn);
+ const swap: ScoreSwap = { key: launch.key, trader: wallet, isBuy: true, quoteRaw: eth(ethIn), tokenRaw, block, logIndex: li, time: t };
+ const first: FirstBuy = { key: launch.key, wallet, block, logIndex: li, time: t, quoteRaw: eth(ethIn), tokenRaw };
+ return { swap, first, holder: { key: launch.key, wallet, balanceRaw: (tokenRaw * BigInt(Math.round(keep * 1000))) / 1000n } };
}
function build(buyers: ReturnType[], over: Partial = {}) {
return input({ swaps: buyers.map((b) => b.swap), firstBuys: buyers.map((b) => b.first), holders: buyers.map((b) => b.holder), ...over });
}
-test("an eligible holder who bought this season and held a day earns the creator 30; unverified 5", () => {
- const b1 = buyer(w(1), START + DAY, 0.01, 5000); // $20 spent, $5 held
- const b2 = buyer(w(2), START + DAY, 0.01, 5000);
+// ── the rules ────────────────────────────────────────────────────────────────
+test("a real holder who bought this season and held a day earns the creator 30 if eligible, 5 if not; half again after a week", () => {
+ const b1 = buyer(w(1), START + DAY, 0.0025); // $5
+ const b2 = buyer(w(2), NOW - 2 * DAY, 0.0025);
const r = scoreSeason(build([b1, b2], { eligible: new Set([w(1)]) }));
const c = r.wallets.get(CREATOR)!;
- // 30 × 1.5 (held 9 days ≥ 7) + 5 × 1.5 + fees: 2 × $20 × 1% × 10
assert.equal(c.creatorWhy.verifiedHolders, 1);
assert.equal(c.creatorWhy.holders, 1);
- assert.equal(c.creator, Math.round(45 + 7.5 + 2 * 20 * 0.01 * 10));
+ // fees come only from eligible traders: w(1)'s $5 at 1% = $0.05 → 0.5 points
+ assert.equal(c.creator, Math.round(30 * 1.5 + 5 + 0.5));
});
-test("holders gained before the season, held under a day, or holding dust earn nothing", () => {
- const before = buyer(w(1), START - DAY, 0.01, 5000);
- const fresh = buyer(w(2), NOW - 3_600_000, 0.01, 5000);
- const dust = buyer(w(3), START + DAY, 0.01, 1000); // $1 held, unverified needs $5
- const r = scoreSeason(build([before, fresh, dust]));
- const c = r.wallets.get(CREATOR);
- assert.equal(c?.creatorWhy.holders ?? 0, 0);
+test("holders gained before the season, held under a day, or below their floor earn nothing", () => {
+ const before = buyer(w(1), START - DAY, 0.01);
+ const fresh = buyer(w(2), NOW - 3_600_000, 0.01);
+ const sold = buyer(w(3), START + DAY, 0.01, 0.1); // keeps $2: under the $5 floor
+ const r = scoreSeason(build([before, fresh, sold]));
+ assert.equal(r.wallets.get(CREATOR)?.creatorWhy.holders ?? 0, 0);
});
-test("the creator, fee recipients, transfer-fed wallets and snipers are never outside", () => {
- const self = buyer(CREATOR, START + DAY, 1, 50000);
- const recip = buyer(RECIP, START + DAY, 1, 50000);
- const sock = buyer(w(9), START + DAY, 1, 50000);
- const sniper = buyer(w(8), START + DAY, 1, 50000, L.launchBlock + 2);
- const r = scoreSeason(build([self, recip, sock, sniper], { linked: new Map([[L.key, new Set([w(9)])]]), eligible: new Set([w(9), w(8)]) }));
- assert.equal(r.wallets.get(CREATOR)?.creator ?? 0, 0, "no holder points and no fee points from insiders");
+test("eligible holders need only $1 held; a first buy under $5 is never a real buyer", () => {
+ const verified = buyer(w(1), START + DAY, 0.0025, 0.3); // $1.50 held
+ const small = buyer(w(2), START + DAY, 0.002); // $4 buy
+ const r = scoreSeason(build([verified, small], { eligible: new Set([w(1), w(2)]) }));
+ assert.equal(r.wallets.get(CREATOR)!.creatorWhy.verifiedHolders, 1);
});
-test("wash trading your own token earns nothing; a 0% fee token earns no fee points", () => {
- const wash = Array.from({ length: 20 }, (_, i) => ({ key: L.key, trader: CREATOR, isBuy: i % 2 === 0, quoteRaw: eth(5), tokenRaw: 1000n * E18, block: 2000 + i, time: START + DAY + i }));
- assert.equal(scoreSeason(input({ swaps: wash })).wallets.get(CREATOR)?.creator ?? 0, 0);
- const free = { ...L, lpFee: 0 };
- const b = buyer(w(1), START + DAY, 1, 1);
- assert.equal(scoreSeason(build([b], { launches: [free], holders: [] })).wallets.get(CREATOR)?.creatorWhy.feesUsd ?? 0, 0);
+test("the creator, fee recipients, transfer-fed wallets and snipers (by block or by the first 12 s) are never real buyers", () => {
+ const self = buyer(CREATOR, START + DAY, 1);
+ const recip = buyer(RECIP, START + DAY, 1);
+ const sock = buyer(w(9), START + DAY, 1);
+ const blockSniper = buyer(w(8), START + DAY, 1, 1, L, L.launchBlock + 2);
+ const fresh: ScoreLaunch = { ...L, key: "base:0xfresh", launchTime: START + DAY };
+ const timeSniper = buyer(w(7), START + DAY + 5_000, 1, 1, fresh, 99_999);
+ const r = scoreSeason(build([self, recip, sock, blockSniper, timeSniper], { launches: [L, fresh], linked: new Map([[L.key, new Set([w(9)])]]), eligible: new Set([w(7), w(8), w(9)]) }));
+ assert.equal(r.wallets.get(CREATOR)?.creator ?? 0, 0);
+ assert.equal(r.realHolders.get(L.key) ?? 0, 0);
});
-test("a creator who sold half of what they bought this season scores 0 for that token; selling fee tokens alone is fine", () => {
- const b = buyer(w(1), START + DAY, 0.5, 5000);
- const dump: ScoreSwap = { key: L.key, trader: CREATOR, isBuy: false, quoteRaw: eth(1), tokenRaw: 600n * E18, block: 3000, time: START + 2 * DAY };
- const dumped = scoreSeason(build([b], { swaps: [b.swap, dump], launcherBought: new Map([[L.key, 1000n * E18]]) }));
- assert.equal(dumped.wallets.get(CREATOR)?.creator ?? 0, 0);
- assert.equal(dumped.wallets.get(CREATOR)?.creatorWhy.dumped, 1);
- const feeSale = scoreSeason(build([b], { swaps: [b.swap, dump] })); // never bought: those were fee tokens
- assert.ok((feeSale.wallets.get(CREATOR)?.creator ?? 0) > 0);
+test("only the best 3 tokens a wallet launched on one day count, and each holder counts once per creator", () => {
+ const launches = Array.from({ length: 5 }, (_, i) => ({ ...L, key: `base:0xd${i}`, launchTime: START - DAY + i }));
+ const buyers = launches.flatMap((l, i) => Array.from({ length: i + 1 }, (_, j) => buyer(w(100 * (i + 1) + j), START + DAY, 0.003, 1, l)));
+ const r = scoreSeason(build(buyers, { launches }));
+ assert.equal(r.wallets.get(CREATOR)!.creatorWhy.tokens, 3);
+ assert.equal(r.wallets.get(CREATOR)!.creatorWhy.holders, 5 + 4 + 3);
+ // one eligible fan holding all five tokens counts once
+ const fan = launches.map((l) => buyer(w(1), START + DAY, 0.003, 1, l));
+ const r2 = scoreSeason(build(fan, { launches, eligible: new Set([w(1)]) }));
+ assert.equal(r2.wallets.get(CREATOR)!.creatorWhy.verifiedHolders, 1);
});
-test("only the best 3 tokens a wallet launched on one day count", () => {
- const launches = Array.from({ length: 5 }, (_, i) => ({ ...L, key: `base:0xt${i}`, launchTime: START + DAY + i }));
- const swaps: ScoreSwap[] = launches.map((l, i) => ({ key: l.key, trader: w(i + 1), isBuy: true, quoteRaw: eth(0.01 * (i + 1)), tokenRaw: E18, block: 5000, time: START + 2 * DAY }));
- const r = scoreSeason(input({ launches, swaps }));
- assert.equal(r.wallets.get(CREATOR)!.creatorWhy.tokens, 3);
- // fees: $20, $40 … $100 at 1% → 2, 4, 6, 8, 10 points; best three = 10 + 8 + 6
- assert.equal(r.wallets.get(CREATOR)!.creator, 24);
+test("dump: sold this season and now keeps under half of what was bought → the token scores 0; selling fee tokens is fine", () => {
+ const b = buyer(w(1), START + DAY, 0.01);
+ const sell: ScoreSwap = { key: L.key, trader: CREATOR, isBuy: false, quoteRaw: eth(0.1), tokenRaw: 500_000n * E18, block: 3000, logIndex: 1, time: START + 2 * DAY };
+ const bought = new Map([[L.key, 1_000_000n * E18]]);
+ const dumped = scoreSeason(build([b], { swaps: [b.swap, sell], launcherBought: bought, holders: [b.holder, { key: L.key, wallet: CREATOR, balanceRaw: 400_000n * E18 }] }));
+ assert.equal(dumped.wallets.get(CREATOR)?.creator ?? 0, 0);
+ const feeSale = scoreSeason(build([b], { swaps: [b.swap, sell], launcherBought: bought, holders: [b.holder, { key: L.key, wallet: CREATOR, balanceRaw: 1_000_000n * E18 }] }));
+ assert.ok((feeSale.wallets.get(CREATOR)?.creator ?? 0) > 0, "sold 500k fee tokens but still keeps everything bought");
});
-test("alive at day 7: a week old, 10+ outside holders, an outside trade in the last day", () => {
- const holders = Array.from({ length: 10 }, (_, i) => buyer(w(i + 1), NOW - 2 * 3_600_000, 0.01, 5000));
- const r = scoreSeason(build(holders));
- assert.equal(r.wallets.get(CREATOR)!.creatorWhy.alive, 1);
+test("early: first 25 real buyers of a token with 50+ real holders; +50 only while half of that buy is held", () => {
+ const buyers = Array.from({ length: 52 }, (_, i) => buyer(w(i + 1), START + DAY + i * 1000, 0.003, i === 0 ? 0.3 : 1, L, 1000 + i));
+ const r = scoreSeason(build(buyers));
+ assert.equal(r.realHolders.get(L.key), 52 - 1, "w(1) kept $1.80: not a real holder (unverified needs $5)");
+ assert.equal(r.wallets.get(w(1))!.scoutWhy.early, 1);
+ assert.equal(r.wallets.get(w(2))!.scout - r.wallets.get(w(1))!.scout, RULES.earlyStillHolding + RULES.hold);
+ assert.equal(r.wallets.get(w(26))?.scoutWhy.early ?? 0, 0);
});
-test("early: the first 25 outside buyers of a token that reached 50 holders, +50 while holding", () => {
- const buyers = Array.from({ length: 30 }, (_, i) => buyer(w(i + 1), START + DAY + i * 1000, 0.01, i === 0 ? 0 : 5000, 1000 + i));
- const inp = build(buyers, { maxOutsideBefore: new Map([[L.key, 50]]) });
- const r = scoreSeason(inp);
+test("early slots go to real buyers only: sub-$5 first buys cannot crowd them out", () => {
+ const crowd = Array.from({ length: 25 }, (_, i) => buyer(w(500 + i), START + DAY + i, 0.001, 1, L, 900 + i)); // $2 each
+ const real = Array.from({ length: 52 }, (_, i) => buyer(w(i + 1), START + DAY + 100 + i, 0.003, 1, L, 1000 + i));
+ const r = scoreSeason(build([...crowd, ...real]));
assert.equal(r.wallets.get(w(1))!.scoutWhy.early, 1);
- assert.equal(r.wallets.get(w(2))!.scout - r.wallets.get(w(1))!.scout, RULES.earlyStillHolding + RULES.hold, "w(1) sold: no +50 and no hold");
- assert.equal(r.wallets.get(w(26))?.scoutWhy.early ?? 0, 0, "26th buyer is not early");
- const small = scoreSeason(build(buyers));
- assert.equal(small.wallets.get(w(2))?.scoutWhy.early ?? 0, 0, "token never reached 50: nobody is early");
});
-test("holds: ≥ $5 buy still held a day later, at most 20 tokens per day", () => {
+test("holds: first buy ≥ $5 this season, half still held a day later; 20 tokens a day", () => {
const launches = Array.from({ length: 25 }, (_, i) => ({ ...L, key: `base:0xh${i}` }));
- const swaps = launches.map((l) => ({ key: l.key, trader: w(1), isBuy: true, quoteRaw: eth(0.01), tokenRaw: 5000n * E18, block: 5000, time: START + DAY }));
- const holders = launches.map((l) => ({ key: l.key, wallet: w(1), balanceRaw: 5000n * E18 }));
- const r = scoreSeason(input({ launches, swaps, holders }));
+ const buys = launches.map((l) => buyer(w(1), START + DAY, 0.003, 1, l));
+ const r = scoreSeason(build(buys, { launches }));
assert.equal(r.wallets.get(w(1))!.scoutWhy.holds, RULES.holdTokensPerDay);
- const tiny = scoreSeason(input({ launches: [L], swaps: [{ ...swaps[0], key: L.key, quoteRaw: eth(0.001) }], holders: [{ key: L.key, wallet: w(1), balanceRaw: E18 }] }));
- assert.equal(tiny.wallets.get(w(1))?.scoutWhy.holds ?? 0, 0, "$2 buy earns nothing");
+ const soldDown = scoreSeason(build([buyer(w(2), START + DAY, 0.003, 0.4)]));
+ assert.equal(soldDown.wallets.get(w(2))?.scoutWhy.holds ?? 0, 0, "sold down to 40%: no hold");
});
-test("scout fee points only on tokens with 20+ outside holders, capped per token per day", () => {
- const big: ScoreSwap[] = Array.from({ length: 40 }, (_, i) => ({ key: L.key, trader: w(1), isBuy: i % 2 === 0, quoteRaw: eth(1), tokenRaw: E18, block: 6000 + i, time: START + 2 * DAY + i }));
- const r = scoreSeason(input({ swaps: big, maxOutsideBefore: new Map([[L.key, 20]]) }));
- assert.equal(Math.round(r.wallets.get(w(1))!.scout), RULES.scoutFeeCapPerTokenDay);
- const r2 = scoreSeason(input({ swaps: big }));
- assert.equal(r2.wallets.get(w(1))?.scout ?? 0, 0, "token without 20 outside holders");
+test("scout fees: 20+ real holders, ≥ $5 trades, at most 200 a day across all tokens", () => {
+ const holders = Array.from({ length: 20 }, (_, i) => buyer(w(100 + i), START + DAY, 0.003));
+ const trades: ScoreSwap[] = Array.from({ length: 60 }, (_, i) => ({ key: L.key, trader: w(1), isBuy: i % 2 === 0, quoteRaw: eth(1), tokenRaw: E18, block: 6000 + i, logIndex: i, time: START + 2 * DAY + i }));
+ const r = scoreSeason(build(holders, { swaps: [...holders.map((h) => h.swap), ...trades] }));
+ assert.equal(Math.round(r.wallets.get(w(1))!.scoutWhy.feesUsd * 10) <= RULES.scoutFeeCapPerDay * 10, true);
+ assert.equal(r.wallets.get(w(1))!.scout, RULES.scoutFeeCapPerDay);
+ const thin = scoreSeason(input({ swaps: trades }));
+ assert.equal(thin.wallets.get(w(1))?.scout ?? 0, 0, "no real holders: no scout fee points");
+});
+
+// ── the exploits the review found, as regressions ────────────────────────────
+test("exploit: an unpriced quote (a farmer's own ERC-20) earns nothing at all", () => {
+ const fake: ScoreLaunch = { ...L, key: "base:0xfake", quoteUsd: null, tokenUsd: null, launchTime: START + DAY };
+ const socks = Array.from({ length: 50 }, (_, i) => buyer(w(i + 1), START + DAY + 60_000 + i, 1, 1, fake, 500 + i));
+ const r = scoreSeason(build(socks, { launches: [fake], eligible: new Set([w(1)]) }));
+ assert.equal(r.wallets.size, 0);
+});
+
+test("exploit: dust sent to 50 wallets makes none of them holders and unlocks nothing", () => {
+ const scout = buyer(w(1), START + DAY, 0.0025, 0); // bought $5, sold to dust
+ const dust = Array.from({ length: 50 }, (_, i) => ({ key: L.key, wallet: w(1000 + i), balanceRaw: 1n }));
+ const r = scoreSeason(input({ swaps: [scout.swap], firstBuys: [scout.first], holders: [{ ...scout.holder, balanceRaw: 1n }, ...dust], eligible: new Set([w(1)]) }));
+ assert.equal(r.realHolders.get(L.key) ?? 0, 0);
+ assert.equal(r.wallets.get(w(1))?.scoutWhy.early ?? 0, 0);
+ assert.equal(r.wallets.get(w(1))?.scoutWhy.holds ?? 0, 0);
+});
+
+test("exploit: wash trading by an unverified wallet earns the creator nothing; by a verified one, at most 50 a day", () => {
+ const wash: ScoreSwap[] = Array.from({ length: 100 }, (_, i) => ({ key: L.key, trader: "0xpuppet", isBuy: i % 2 === 0, quoteRaw: eth(10), tokenRaw: E18, block: 1000 + i, logIndex: i, time: START + 2 * DAY + i * 1000 }));
+ assert.equal(scoreSeason(input({ swaps: wash })).wallets.get(CREATOR)?.creator ?? 0, 0);
+ const capped = scoreSeason(input({ swaps: wash, eligible: new Set(["0xpuppet"]) }));
+ assert.equal(capped.wallets.get(CREATOR)!.creator, RULES.creatorFeeCapPerTraderDay);
});
-test("a wallet kept off points scores nothing and counts for no one", () => {
- const b = buyer(w(1), START + DAY, 0.01, 5000);
+test("exploit: a wallet kept off points (deleted profile or not) scores nothing and counts for no one", () => {
+ const b = buyer(w(1), START + DAY, 0.01);
const r = scoreSeason(build([b], { flagged: new Set([w(1)]), eligible: new Set([w(1)]) }));
assert.equal(r.wallets.has(w(1)), false);
assert.equal(r.wallets.get(CREATOR)?.creatorWhy.verifiedHolders ?? 0, 0);
});
-test("unpriced quotes: holders count without a dollar floor, fees earn nothing", () => {
- const unpriced = { ...L, quoteUsd: null, tokenUsd: null };
- const b = buyer(w(1), START + DAY, 0.0001, 1);
- const r = scoreSeason(build([b], { launches: [unpriced] }));
- assert.equal(r.wallets.get(CREATOR)!.creatorWhy.holders, 1);
- assert.equal(r.wallets.get(CREATOR)!.creatorWhy.feesUsd, 0);
+// ── eligibility, ranks, why ──────────────────────────────────────────────────
+test("eligibility and the reason shown when a wallet is not on the board", () => {
+ const ok = { x_status: "verified", x_account_created: "2025-01-01T00:00:00Z", x_followers: 50, points_flag: null, hidden: false, deleted_at: null };
+ assert.ok(isEligible(ok, NOW));
+ assert.equal(notEligibleReason(ok, NOW), null);
+ assert.equal(notEligibleReason(null, NOW), "no_profile");
+ assert.equal(notEligibleReason({ ...ok, deleted_at: "2026-10-01T00:00:00Z" }, NOW), "no_profile");
+ assert.equal(notEligibleReason({ ...ok, x_status: "none" }, NOW), "not_verified");
+ assert.equal(notEligibleReason({ ...ok, x_account_created: new Date(NOW - 5 * DAY).toISOString() }, NOW), "account_too_new");
+ assert.equal(notEligibleReason({ ...ok, x_followers: 3 }, NOW), "few_followers");
+ assert.equal(notEligibleReason({ ...ok, points_flag: "excluded" }, NOW), "kept_off");
+ assert.ok(!isEligible({ ...ok, deleted_at: "2026-10-01T00:00:00Z" }, NOW));
});
test("ranks only eligible wallets, ties share a rank; why lines read plainly", () => {
+ const z = { tokens: 1, verifiedHolders: 0, holders: 0, feesUsd: 0, dumped: 0 };
+ const sc = { early: 0, holds: 0, feesUsd: 0 };
const scores = [
- { wallet: "a", creator: 50, scout: 0, total: 50, creatorWhy: { tokens: 1, verifiedHolders: 2, holders: 1, feesUsd: 12.5, alive: 1, dumped: 0 }, scoutWhy: { early: 0, holds: 0, feesUsd: 0 } },
- { wallet: "b", creator: 50, scout: 0, total: 50, creatorWhy: { tokens: 1, verifiedHolders: 0, holders: 0, feesUsd: 0, alive: 0, dumped: 0 }, scoutWhy: { early: 0, holds: 0, feesUsd: 0 } },
- { wallet: "c", creator: 90, scout: 0, total: 90, creatorWhy: { tokens: 1, verifiedHolders: 0, holders: 0, feesUsd: 0, alive: 0, dumped: 0 }, scoutWhy: { early: 0, holds: 0, feesUsd: 0 } },
+ { wallet: "a", creator: 50, scout: 0, total: 50, creatorWhy: { ...z, verifiedHolders: 2, holders: 1, feesUsd: 12.5 }, scoutWhy: sc },
+ { wallet: "b", creator: 50, scout: 0, total: 50, creatorWhy: z, scoutWhy: sc },
+ { wallet: "c", creator: 90, scout: 0, total: 90, creatorWhy: z, scoutWhy: sc },
];
- const ranks = rankBy(scores, "creator", new Set(["a", "b"]));
- assert.deepEqual([...ranks.entries()], [["a", 1], ["b", 1]], "c is not eligible");
- assert.equal(whyLine("creator", scores[0]), "2 verified holders · 1 holder · $12.50 fees · 1 alive at day 7");
+ assert.deepEqual([...rankBy(scores, "creator", new Set(["a", "b"])).entries()], [["a", 1], ["b", 1]]);
+ assert.equal(whyLine("creator", scores[0]), "2 verified holders · 1 holder · $12.50 fees from verified traders");
});
diff --git a/app/src/lib/points/score.ts b/app/src/lib/points/score.ts
index e64db813..99561bfb 100644
--- a/app/src/lib/points/score.ts
+++ b/app/src/lib/points/score.ts
@@ -1,40 +1,42 @@
/**
* Season points, the pure half (node --test loads this directly; relative imports only).
*
- * Points are reputation for launching and trading, recomputed from scratch from the chain index. The rules reward what
- * costs real money or reputation to fake and nothing that is free to repeat:
+ * Points are reputation for launching and trading, recomputed from scratch from the chain index. Every rule is
+ * built on two definitions that cost real money to fake:
*
- * Creator (per token the wallet launched; any token, only activity inside the season counts)
- * - each outside buyer whose first buy was in the season and still holds after 24 h: 30 if their wallet is
- * points-eligible (X-verified, account age and followers), else 5; the eligible holder needs ≥ $1 held,
- * the other ≥ $5; still holding at day 7 adds half again
- * - 10 per $1 of trading fees generated by outside trades in the season (the fee exists whoever it is paid to)
- * - 100 once if the token is a week old, has ≥ 10 outside holders and an outside trade in the last day
- * - the token scores 0 if, during the season, the creator sold half or more of what they ever bought of it
+ * real buyer = a wallet whose FIRST buy of a token was ≥ $5, outside the sniper window (the launch block + 3, and
+ * the first 12 seconds), and that is not the launcher, a fee recipient, a protocol address, a wallet
+ * that got the token by transfer from the launcher or a recipient, or a wallet kept off points
+ * real holder = a real buyer still holding ≥ $5 of it (≥ $1 if their profile is points-eligible)
+ *
+ * Only tokens whose quote has a price count at all. Then:
+ *
+ * Creator (any token the wallet launched; only activity inside the season counts)
+ * - each real holder whose first buy was in the season, a day or more ago: 30 if their profile is points-eligible,
+ * else 5; a week or more ago: half again. A holder counts once per creator (their best token).
+ * - 10 per $1 of fees from trades ≥ $5 by points-eligible outside traders; at most 50 per trader per creator per day
+ * - a token scores 0 if its creator sold during the season and now keeps less than half of what they bought
* - of the tokens a wallet launched on one UTC day, only the best 3 count
* Scout (traders)
- * - 100 for being one of the first 25 outside buyers (first buy ≥ $5, in the season) of a token that has reached
- * 50 outside holders; 50 more while still holding
- * - 5 per token bought (≥ $5, in the season) and still held 24 h later; at most 20 tokens per UTC day
- * - 10 per $1 of fees paid on other people's tokens that have ≥ 20 outside holders; at most 200 per token per day
+ * - one of the first 25 real buyers of a token that now has 50+ real holders, buying in the season: 100, and 50
+ * more while still holding half of that first buy
+ * - 5 per token first bought in the season (≥ $5) and still half-held a day later; at most 20 tokens per UTC day
+ * - 10 per $1 of fees paid (trades ≥ $5) on other people's tokens with 20+ real holders; at most 200 a day
*
- * "Outside" = not the launcher, not a fee recipient, not a protocol address, not a wallet that got the token by
- * transfer from the launcher or a recipient, not a wallet an admin kept off points, and never a buy inside the
- * sniper window (launch block + SNIPER_BLOCKS). Trades under $5 earn nothing.
+ * Only points-eligible wallets rank; everyone else sees what is waiting for them.
*/
export const RULES = {
sniperBlocks: 3,
+ sniperMs: 12_000,
minTradeUsd: 5,
- verifiedHolder: 30,
+ holderMinUsd: 5,
verifiedHolderMinUsd: 1,
+ verifiedHolder: 30,
holder: 5,
- holderMinUsd: 5,
- day7Bonus: 0.5,
+ weekBonus: 0.5,
creatorFeePointsPerUsd: 10,
- alive: 100,
- aliveMinHolders: 10,
- aliveAgeMs: 7 * 86_400_000,
+ creatorFeeCapPerTraderDay: 50,
bestTokensPerDay: 3,
early: 100,
earlyStillHolding: 50,
@@ -42,9 +44,13 @@ export const RULES = {
earlyHoldersTarget: 50,
hold: 5,
holdTokensPerDay: 20,
+ heldShare: 0.5,
scoutFeePointsPerUsd: 10,
- scoutFeeCapPerTokenDay: 200,
+ scoutFeeCapPerDay: 200,
scoutFeeMinHolders: 20,
+ // points-eligible profile: X-verified on an account this old with this many followers
+ eligibleMinAgeDays: 30,
+ eligibleMinFollowers: 20,
} as const;
const DAY = 86_400_000;
@@ -57,12 +63,12 @@ export type ScoreLaunch = {
launchTime: number; // ms
lpFee: number; // pips (10_000 = 1%)
quoteDecimals: number;
- quoteUsd: number | null; // USD per whole quote unit; null = unpriced (no USD-gated points)
- tokenUsd: number | null; // USD per whole token now
+ quoteUsd: number | null; // USD per whole quote unit; null = unpriced → the token is out of scoring
+ tokenUsd: number | null; // USD per whole token now; null → out of scoring
};
-export type ScoreSwap = { key: string; trader: string; isBuy: boolean; quoteRaw: bigint; tokenRaw: bigint; block: number; time: number };
-/** A wallet's first buy of a token, all-time (rank among outside buyers decides "early"). */
-export type FirstBuy = { key: string; wallet: string; block: number; time: number; quoteRaw: bigint };
+export type ScoreSwap = { key: string; trader: string; isBuy: boolean; quoteRaw: bigint; tokenRaw: bigint; block: number; logIndex: number; time: number };
+/** A wallet's first buy of a token, all-time (its rank among real buyers decides "early"). */
+export type FirstBuy = { key: string; wallet: string; block: number; logIndex: number; time: number; quoteRaw: bigint; tokenRaw: bigint };
export type ScoreInput = {
seasonStart: number;
@@ -72,7 +78,7 @@ export type ScoreInput = {
/** swaps inside the season, any order */
swaps: readonly ScoreSwap[];
firstBuys: readonly FirstBuy[];
- /** current balances (raw, 18 decimals) of the tokens in scope */
+ /** current balances (raw, 18 decimals) of the tokens in scope, for wallets that ever bought them */
holders: readonly { key: string; wallet: string; balanceRaw: bigint }[];
/** the launcher's all-time bought amount (raw) per token */
launcherBought: ReadonlyMap;
@@ -81,183 +87,185 @@ export type ScoreInput = {
/** protocol addresses (pool manager, routers, locker, factory, zero, dead) */
system: ReadonlySet;
eligible: ReadonlySet;
- /** kept off points by an admin: never points, never counted as anyone's outside holder or buyer */
+ /** kept off points by an admin: never points, never anyone's buyer, holder or trader */
flagged: ReadonlySet;
- /** the most outside holders each token had earlier this season */
- maxOutsideBefore: ReadonlyMap;
};
-export type CreatorBreakdown = { tokens: number; verifiedHolders: number; holders: number; feesUsd: number; alive: number; dumped: number };
+export type CreatorBreakdown = { tokens: number; verifiedHolders: number; holders: number; feesUsd: number; dumped: number };
export type ScoutBreakdown = { early: number; holds: number; feesUsd: number };
export type WalletScore = { wallet: string; creator: number; scout: number; total: number; creatorWhy: CreatorBreakdown; scoutWhy: ScoutBreakdown };
-export type ScoreResult = { wallets: Map; maxOutside: Map };
+export type ScoreResult = { wallets: Map; realHolders: Map };
const units = (raw: bigint, decimals: number) => Number(raw) / 10 ** decimals;
const utcDay = (ms: number) => Math.floor(ms / DAY);
export function scoreSeason(input: ScoreInput): ScoreResult {
const end = Math.min(input.now, input.seasonEnd);
- const launches = new Map(input.launches.map((l) => [l.key, l]));
+ // only priced tokens take part: an unpriced quote has no dollar floor to hold anything to
+ const launches = new Map(input.launches.filter((l) => l.quoteUsd !== null && l.tokenUsd !== null && l.quoteUsd > 0 && l.tokenUsd > 0).map((l) => [l.key, l]));
const wallets = new Map();
const get = (w: string) => {
let s = wallets.get(w);
if (!s) {
- s = { wallet: w, creator: 0, scout: 0, total: 0, creatorWhy: { tokens: 0, verifiedHolders: 0, holders: 0, feesUsd: 0, alive: 0, dumped: 0 }, scoutWhy: { early: 0, holds: 0, feesUsd: 0 } };
+ s = { wallet: w, creator: 0, scout: 0, total: 0, creatorWhy: { tokens: 0, verifiedHolders: 0, holders: 0, feesUsd: 0, dumped: 0 }, scoutWhy: { early: 0, holds: 0, feesUsd: 0 } };
wallets.set(w, s);
}
return s;
};
-
const insider = (l: ScoreLaunch, w: string) => w === l.launcher || l.recipients.includes(w) || input.system.has(w) || input.flagged.has(w) || (input.linked.get(l.key)?.has(w) ?? false);
- const usdOfQuote = (l: ScoreLaunch, raw: bigint) => (l.quoteUsd === null ? null : units(raw, l.quoteDecimals) * l.quoteUsd);
- const usdOfTokens = (l: ScoreLaunch, raw: bigint) => (l.tokenUsd === null ? null : units(raw, 18) * l.tokenUsd);
+ const sniper = (l: ScoreLaunch, block: number, time: number) => block <= l.launchBlock + RULES.sniperBlocks || time - l.launchTime < RULES.sniperMs;
+ const quoteUsd = (l: ScoreLaunch, raw: bigint) => units(raw, l.quoteDecimals) * (l.quoteUsd as number);
+ const tokenUsd = (l: ScoreLaunch, raw: bigint) => units(raw, 18) * (l.tokenUsd as number);
const inSeason = (t: number) => t >= input.seasonStart && t < end;
+ const balanceOf = new Map();
+ for (const h of input.holders) if (h.balanceRaw > 0n) balanceOf.set(`${h.key}|${h.wallet}`, h.balanceRaw);
+ const bal = (key: string, w: string) => balanceOf.get(`${key}|${w}`) ?? 0n;
+ const halfHeld = (key: string, w: string, boughtRaw: bigint) => bal(key, w) * 2n >= boughtRaw && bal(key, w) > 0n;
- // first buys: the outside buyers of each token in buy order (sniper-window buys never count)
- const firstByToken = new Map();
+ // real buyers per token, in buy order
+ const realBuyers = new Map();
for (const f of input.firstBuys) {
const l = launches.get(f.key);
- if (!l || insider(l, f.wallet) || f.block <= l.launchBlock + RULES.sniperBlocks) continue;
- const list = firstByToken.get(f.key) ?? [];
+ if (!l || insider(l, f.wallet) || sniper(l, f.block, f.time) || quoteUsd(l, f.quoteRaw) < RULES.minTradeUsd) continue;
+ const list = realBuyers.get(f.key) ?? [];
list.push(f);
- firstByToken.set(f.key, list);
+ realBuyers.set(f.key, list);
}
- for (const list of firstByToken.values()) list.sort((a, b) => a.block - b.block || a.time - b.time);
- const firstOf = new Map();
- for (const [key, list] of firstByToken) for (const f of list) firstOf.set(`${key}|${f.wallet}`, f);
+ for (const list of realBuyers.values()) list.sort((a, b) => a.block - b.block || a.logIndex - b.logIndex || a.time - b.time);
- // current outside holders per token
- const balance = new Map();
- const outsideHolders = new Map();
- for (const h of input.holders) {
- if (h.balanceRaw <= 0n) continue;
- balance.set(`${h.key}|${h.wallet}`, h.balanceRaw);
- const l = launches.get(h.key);
- if (!l || insider(l, h.wallet)) continue;
- const list = outsideHolders.get(h.key) ?? [];
- list.push(h.wallet);
- outsideHolders.set(h.key, list);
+ // real holders per token (real buyers still holding enough)
+ const realHolders = new Map();
+ for (const [key, list] of realBuyers) {
+ const l = launches.get(key)!;
+ const held = list.filter((f) => tokenUsd(l, bal(key, f.wallet)) >= (input.eligible.has(f.wallet) ? RULES.verifiedHolderMinUsd : RULES.holderMinUsd));
+ realHolders.set(key, held);
}
- const maxOutside = new Map();
- for (const l of input.launches) maxOutside.set(l.key, Math.max(input.maxOutsideBefore.get(l.key) ?? 0, outsideHolders.get(l.key)?.length ?? 0));
+ const holderCount = (key: string) => realHolders.get(key)?.length ?? 0;
- // the season's outside swaps, and what each token's creator sold
- const outsideSwaps: ScoreSwap[] = [];
- const lastOutsideTrade = new Map();
- const creatorSold = new Map();
+ // the season's outside trades (≥ $5, outside the sniper window), and what each creator sold
+ const outside: ScoreSwap[] = [];
+ const creatorSold = new Set();
for (const s of input.swaps) {
const l = launches.get(s.key);
if (!l || !inSeason(s.time)) continue;
- if (s.trader === l.launcher && !s.isBuy) creatorSold.set(s.key, (creatorSold.get(s.key) ?? 0n) + s.tokenRaw);
- if (insider(l, s.trader) || s.block <= l.launchBlock + RULES.sniperBlocks) continue;
- outsideSwaps.push(s);
- lastOutsideTrade.set(s.key, Math.max(lastOutsideTrade.get(s.key) ?? 0, s.time));
+ if (s.trader === l.launcher && !s.isBuy) creatorSold.add(s.key);
+ if (insider(l, s.trader) || sniper(l, s.block, s.time) || quoteUsd(l, s.quoteRaw) < RULES.minTradeUsd) continue;
+ outside.push(s);
}
- // ── creator points, per token, then the best 3 per launcher per launch day ──
- type TokenCreator = { l: ScoreLaunch; points: number; verifiedHolders: number; holders: number; feesUsd: number; alive: number; dumped: boolean };
+ // ── creator points ──
+ type TokenCreator = { l: ScoreLaunch; holders: { wallet: string; pts: number; eligible: boolean }[]; fees: { trader: string; day: number; usd: number }[]; dumped: boolean };
const perToken = new Map();
const ensure = (l: ScoreLaunch) => {
let t = perToken.get(l.key);
if (!t) {
- t = { l, points: 0, verifiedHolders: 0, holders: 0, feesUsd: 0, alive: 0, dumped: false };
+ t = { l, holders: [], fees: [], dumped: false };
perToken.set(l.key, t);
}
return t;
};
- for (const [key, holdersList] of outsideHolders) {
+ for (const [key, list] of realHolders) {
const l = launches.get(key)!;
- for (const w of holdersList) {
- const f = firstOf.get(`${key}|${w}`);
- if (!f || !inSeason(f.time) || end - f.time < DAY) continue; // gained this season, held a day
- const eligible = input.eligible.has(w);
- const value = usdOfTokens(l, balance.get(`${key}|${w}`) ?? 0n);
- if (value !== null && value < (eligible ? RULES.verifiedHolderMinUsd : RULES.holderMinUsd)) continue;
- const base = eligible ? RULES.verifiedHolder : RULES.holder;
- const t = ensure(l);
- t.points += base * (end - f.time >= 7 * DAY ? 1 + RULES.day7Bonus : 1);
- if (eligible) t.verifiedHolders++;
- else t.holders++;
+ for (const f of list) {
+ if (!inSeason(f.time) || end - f.time < DAY) continue; // gained this season, held a day
+ const eligible = input.eligible.has(f.wallet);
+ const pts = (eligible ? RULES.verifiedHolder : RULES.holder) * (end - f.time >= 7 * DAY ? 1 + RULES.weekBonus : 1);
+ ensure(l).holders.push({ wallet: f.wallet, pts, eligible });
}
}
- for (const s of outsideSwaps) {
+ for (const s of outside) {
+ if (!input.eligible.has(s.trader)) continue; // fees count only from points-eligible traders: wash trades need real accounts
const l = launches.get(s.key)!;
- const usd = usdOfQuote(l, s.quoteRaw);
- if (usd === null) continue;
- const fee = (usd * l.lpFee) / 1_000_000;
- if (fee <= 0) continue;
- const t = ensure(l);
- t.feesUsd += fee;
- t.points += fee * RULES.creatorFeePointsPerUsd;
- }
- for (const l of input.launches) {
- if (end - l.launchTime < RULES.aliveAgeMs) continue;
- if ((outsideHolders.get(l.key)?.length ?? 0) < RULES.aliveMinHolders) continue;
- if (end - (lastOutsideTrade.get(l.key) ?? 0) > DAY) continue;
- const t = ensure(l);
- t.alive = 1;
- t.points += RULES.alive;
+ const fee = (quoteUsd(l, s.quoteRaw) * l.lpFee) / 1_000_000;
+ if (fee > 0) ensure(l).fees.push({ trader: s.trader, day: utcDay(s.time), usd: fee });
}
for (const t of perToken.values()) {
const bought = input.launcherBought.get(t.l.key) ?? 0n;
- const sold = creatorSold.get(t.l.key) ?? 0n;
- // only against what the creator bought: selling the token-side fees the pool paid them is not a dump
- if (bought > 0n && sold * 2n >= bought) {
- t.dumped = true;
- t.points = 0;
- }
+ // a dump = sold during the season and now keeps less than half of what was bought (selling fee tokens is not one)
+ if (bought > 0n && creatorSold.has(t.l.key) && bal(t.l.key, t.l.launcher) * 2n < bought) t.dumped = true;
}
- const byLauncherDay = new Map();
+ // per creator: each holder once (their best token), fees capped per trader per day, best 3 tokens per launch day
+ const byCreator = new Map();
for (const t of perToken.values()) {
- const k = `${t.l.launcher}|${utcDay(t.l.launchTime)}`;
- const list = byLauncherDay.get(k) ?? [];
+ if (input.flagged.has(t.l.launcher)) continue;
+ const list = byCreator.get(t.l.launcher) ?? [];
list.push(t);
- byLauncherDay.set(k, list);
+ byCreator.set(t.l.launcher, list);
}
- for (const list of byLauncherDay.values()) {
- list.sort((a, b) => b.points - a.points);
- list.forEach((t, i) => {
- const w = t.l.launcher;
- if (input.flagged.has(w)) return;
- const s = get(w);
+ for (const [creator, tokens] of byCreator) {
+ const s = get(creator);
+ const value = new Map();
+ const counted = new Set();
+ const feeUsed = new Map();
+ // holders: assign each holder to the token where they are worth the most (ties: first)
+ const holderBest = new Map();
+ for (const t of tokens) {
+ if (t.dumped) continue;
+ for (const h of t.holders) {
+ const cur = holderBest.get(h.wallet);
+ if (!cur || h.pts > cur.pts) holderBest.set(h.wallet, { t, pts: h.pts, eligible: h.eligible });
+ }
+ }
+ for (const [, h] of holderBest) value.set(h.t, (value.get(h.t) ?? 0) + h.pts);
+ const feeUsd = new Map();
+ for (const t of tokens) {
+ if (t.dumped) continue;
+ for (const f of t.fees) {
+ const k = `${f.trader}|${f.day}`;
+ const used = feeUsed.get(k) ?? 0;
+ const pts = Math.min(f.usd * RULES.creatorFeePointsPerUsd, RULES.creatorFeeCapPerTraderDay - used);
+ if (pts <= 0) continue;
+ feeUsed.set(k, used + pts);
+ value.set(t, (value.get(t) ?? 0) + pts);
+ feeUsd.set(t, (feeUsd.get(t) ?? 0) + f.usd);
+ }
+ }
+ const byDay = new Map();
+ for (const t of tokens) {
if (t.dumped) s.creatorWhy.dumped++;
- if (i >= RULES.bestTokensPerDay || t.points <= 0) return;
- s.creator += t.points;
- s.creatorWhy.tokens++;
- s.creatorWhy.verifiedHolders += t.verifiedHolders;
- s.creatorWhy.holders += t.holders;
- s.creatorWhy.feesUsd += t.feesUsd;
- s.creatorWhy.alive += t.alive;
- });
+ const d = utcDay(t.l.launchTime);
+ const list = byDay.get(d) ?? [];
+ list.push(t);
+ byDay.set(d, list);
+ }
+ for (const list of byDay.values()) {
+ list.sort((a, b) => (value.get(b) ?? 0) - (value.get(a) ?? 0));
+ for (const t of list.slice(0, RULES.bestTokensPerDay)) {
+ const v = value.get(t) ?? 0;
+ if (v <= 0) continue;
+ s.creator += v;
+ s.creatorWhy.tokens++;
+ s.creatorWhy.feesUsd += feeUsd.get(t) ?? 0;
+ for (const [w, h] of holderBest) {
+ if (h.t !== t || counted.has(w)) continue;
+ counted.add(w);
+ if (h.eligible) s.creatorWhy.verifiedHolders++;
+ else s.creatorWhy.holders++;
+ }
+ }
+ }
}
// ── scout points ──
- for (const [key, list] of firstByToken) {
- const l = launches.get(key)!;
- if ((maxOutside.get(key) ?? 0) < RULES.earlyHoldersTarget) continue;
- list.slice(0, RULES.earlyFirstBuyers).forEach((f) => {
- if (!inSeason(f.time)) return;
- const usd = usdOfQuote(l, f.quoteRaw);
- if (usd !== null && usd < RULES.minTradeUsd) return;
+ for (const [key, list] of realBuyers) {
+ if (holderCount(key) < RULES.earlyHoldersTarget) continue;
+ for (const f of list.slice(0, RULES.earlyFirstBuyers)) {
+ if (!inSeason(f.time)) continue;
const s = get(f.wallet);
- s.scout += RULES.early + ((balance.get(`${key}|${f.wallet}`) ?? 0n) > 0n ? RULES.earlyStillHolding : 0);
+ s.scout += RULES.early + (halfHeld(key, f.wallet, f.tokenRaw) ? RULES.earlyStillHolding : 0);
s.scoutWhy.early++;
- });
+ }
}
- // holds: the first in-season buy of a token (≥ $5), still held a day later; at most 20 tokens per buy day
- const holdsPerDay = new Map();
const firstSeasonBuy = new Map();
- for (const s of outsideSwaps) {
+ for (const s of outside) {
if (!s.isBuy) continue;
const k = `${s.key}|${s.trader}`;
const cur = firstSeasonBuy.get(k);
if (!cur || s.time < cur.time) firstSeasonBuy.set(k, s);
}
+ const holdsPerDay = new Map();
for (const s of [...firstSeasonBuy.values()].sort((a, b) => a.time - b.time)) {
- const l = launches.get(s.key)!;
- const usd = usdOfQuote(l, s.quoteRaw);
- if (usd !== null && usd < RULES.minTradeUsd) continue;
- if (end - s.time < DAY || (balance.get(`${s.key}|${s.trader}`) ?? 0n) <= 0n) continue;
+ if (end - s.time < DAY || !halfHeld(s.key, s.trader, s.tokenRaw)) continue;
const dayKey = `${s.trader}|${utcDay(s.time)}`;
const n = holdsPerDay.get(dayKey) ?? 0;
if (n >= RULES.holdTokensPerDay) continue;
@@ -266,20 +274,17 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
w.scout += RULES.hold;
w.scoutWhy.holds++;
}
- // fees paid on other people's real tokens, capped per token per day
- const feeCap = new Map();
- for (const s of outsideSwaps) {
+ const scoutFeeUsed = new Map();
+ for (const s of outside) {
+ if (holderCount(s.key) < RULES.scoutFeeMinHolders) continue;
const l = launches.get(s.key)!;
- if ((maxOutside.get(s.key) ?? 0) < RULES.scoutFeeMinHolders) continue;
- const usd = usdOfQuote(l, s.quoteRaw);
- if (usd === null || usd < RULES.minTradeUsd) continue;
- const fee = (usd * l.lpFee) / 1_000_000;
+ const fee = (quoteUsd(l, s.quoteRaw) * l.lpFee) / 1_000_000;
if (fee <= 0) continue;
- const capKey = `${s.trader}|${s.key}|${utcDay(s.time)}`;
- const used = feeCap.get(capKey) ?? 0;
- const pts = Math.min(fee * RULES.scoutFeePointsPerUsd, RULES.scoutFeeCapPerTokenDay - used);
+ const k = `${s.trader}|${utcDay(s.time)}`;
+ const used = scoutFeeUsed.get(k) ?? 0;
+ const pts = Math.min(fee * RULES.scoutFeePointsPerUsd, RULES.scoutFeeCapPerDay - used);
if (pts <= 0) continue;
- feeCap.set(capKey, used + pts);
+ scoutFeeUsed.set(k, used + pts);
const w = get(s.trader);
w.scout += pts;
w.scoutWhy.feesUsd += fee;
@@ -296,9 +301,28 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
s.total = s.creator + s.scout;
s.creatorWhy.feesUsd = Math.round(s.creatorWhy.feesUsd * 100) / 100;
s.scoutWhy.feesUsd = Math.round(s.scoutWhy.feesUsd * 100) / 100;
- if (s.total <= 0 && s.creatorWhy.dumped === 0) wallets.delete(w);
+ if (s.total <= 0) wallets.delete(w);
}
- return { wallets, maxOutside };
+ return { wallets, realHolders: new Map([...realHolders].map(([k, v]) => [k, v.length])) };
+}
+
+/** Points-eligible profile: X-verified, public, not kept off points, on an account old and followed enough. */
+export function isEligible(p: { x_status: string; x_account_created: string | null; x_followers: number | null; points_flag: string | null; hidden: boolean; deleted_at?: string | null }, now: number): boolean {
+ if (p.hidden || p.points_flag || p.deleted_at || p.x_status !== "verified") return false;
+ if (!p.x_account_created || p.x_followers === null) return false;
+ return now - new Date(p.x_account_created).getTime() >= RULES.eligibleMinAgeDays * DAY && p.x_followers >= RULES.eligibleMinFollowers;
+}
+
+/** Why a wallet is not on the board yet (for /me): the one step that would change it. */
+export type NotEligibleReason = "no_profile" | "not_verified" | "account_too_new" | "few_followers" | "hidden" | "kept_off";
+export function notEligibleReason(p: { x_status: string; x_account_created: string | null; x_followers: number | null; points_flag: string | null; hidden: boolean; deleted_at?: string | null } | null, now: number): NotEligibleReason | null {
+ if (!p || p.deleted_at) return "no_profile";
+ if (p.points_flag) return "kept_off";
+ if (p.hidden) return "hidden";
+ if (p.x_status !== "verified") return "not_verified";
+ if (!p.x_account_created || now - new Date(p.x_account_created).getTime() < RULES.eligibleMinAgeDays * DAY) return "account_too_new";
+ if (p.x_followers === null || p.x_followers < RULES.eligibleMinFollowers) return "few_followers";
+ return null;
}
/** Ranks among eligible wallets (1 = best); ties share a rank. */
@@ -325,8 +349,7 @@ export function whyLine(board: "creator" | "scout", s: Pick= 0.01) parts.push(`${usd(c.feesUsd)} fees`);
- if (c.alive) parts.push(`${c.alive} alive at day 7`);
+ if (c.feesUsd >= 0.01) parts.push(`${usd(c.feesUsd)} fees from verified traders`);
} else {
const c = s.scoutWhy;
if (c.early) parts.push(`early on ${c.early} token${c.early === 1 ? "" : "s"}`);
diff --git a/app/src/lib/points/server.ts b/app/src/lib/points/server.ts
index 626be3d5..694e7753 100644
--- a/app/src/lib/points/server.ts
+++ b/app/src/lib/points/server.ts
@@ -7,48 +7,60 @@ import { DEAD_ADDR, ZERO_ADDR } from "@/lib/launchpad/holders";
import { systemAddresses } from "@/lib/launchpad/indexer";
import { quotePerToken } from "@/lib/launchpad/math";
import { quotePricer } from "@/lib/launchpad/queries";
-import { pointsEligible } from "@/lib/profiles/xpost";
import { namesFor, type NameEntry } from "@/lib/profiles/server";
-import { rankBy, scoreSeason, whyLine, type CreatorBreakdown, type FirstBuy, type ScoreLaunch, type ScoreSwap, type ScoutBreakdown } from "./score";
+import { isEligible, notEligibleReason, rankBy, scoreSeason, whyLine, type CreatorBreakdown, type FirstBuy, type NotEligibleReason, type ScoreLaunch, type ScoreSwap, type ScoutBreakdown } from "./score";
/**
* Season points, the server half: load one season's chain data, score it (score.ts), replace the season's rows.
- * Runs hourly from the sync loop on ONE machine (session advisory lock) and on demand from /admin. Everything is
- * recomputed from scratch each time, so a rule change or an admin flag applies to the whole season on the next run.
+ * Runs hourly from the sync loop (fire-and-forget, so indexing never waits for it) on ONE machine (session advisory
+ * lock on a reserved connection, which also runs the reads one after another so the request pool stays free), and on
+ * demand from /admin. Everything is recomputed from scratch, so a rule change or an admin flag applies to the whole
+ * season on the next run. A run whose prices are incomplete writes nothing: the last board stays.
*/
-export type Season = { id: number; slug: string; name: string; starts_at: string; ends_at: string; public: boolean };
+export type Season = { id: number; slug: string; name: string; starts_at: string; ends_at: string; public: boolean; computed_at: string | null };
export type Board = "creator" | "scout";
export type BoardRow = { rank: number; wallet: string; points: number; why: string };
export type WalletPoints = { creator: number; scout: number; total: number; eligible: boolean; rank_creator: number | null; rank_scout: number | null; why_creator: string; why_scout: string; computed_at: string };
-/** Eligibility for points (and for the 6× holder weight): env-tunable, never hard-coded in the rules. */
-export function eligibilityConfig(): { minAgeDays: number; minFollowers: number } {
- const n = (v: string | undefined, d: number) => (Number.isFinite(Number(v)) && v !== "" && v !== undefined ? Number(v) : d);
- return { minAgeDays: n(process.env.POINTS_MIN_X_AGE_DAYS, 30), minFollowers: n(process.env.POINTS_MIN_X_FOLLOWERS, 20) };
-}
-
/** The current season: the latest one that has started (a finished one stays as the final standings until the next). */
export async function currentSeason(): Promise {
const db = maybeDb();
if (!db) return null;
- const [s] = await db`SELECT id, slug, name, starts_at, ends_at, public FROM bb_seasons WHERE starts_at <= now() ORDER BY starts_at DESC LIMIT 1`;
+ const [s] = await db`SELECT id, slug, name, starts_at, ends_at, public, computed_at FROM bb_seasons WHERE starts_at <= now() ORDER BY starts_at DESC LIMIT 1`;
return s ?? null;
}
+export function seasonEnded(s: Pick, now = Date.now()): boolean {
+ return new Date(s.ends_at).getTime() <= now;
+}
+
+/** The final standings are fixed once a compute has run after the season's end. */
+function isFinal(s: Season): boolean {
+ return Boolean(s.computed_at && seasonEnded(s) && new Date(s.computed_at).getTime() > new Date(s.ends_at).getTime());
+}
+
// ── compute ──────────────────────────────────────────────────────────────────
type LaunchRowRaw = { chain_id: number; token: string; launcher: string; recipients: { payout: string; bps: number }[]; block_number: bigint; block_time: string; lp_fee: number; quote: string; sqrt_price_x96: string | null };
-export async function computeSeason(db: Db, season: Season): Promise<{ wallets: number; eligible: number; ms: number }> {
+export type ComputeResult = { wallets: number; eligible: number; ms: number } | { skipped: string };
+
+/** `read` runs the season's reads one after another (a reserved connection); `write` (the pool) runs the replace. */
+export async function computeSeason(read: Db, write: Db, season: Season): Promise {
const t0 = Date.now();
const start = new Date(season.starts_at).getTime();
const end = new Date(season.ends_at).getTime();
const now = Date.now();
const until = new Date(Math.min(now, end)).toISOString();
+ // prices first: a run without a complete price picture writes nothing (the last board stays)
+ const usd = await ethUsd();
+ if (usd === null) return { skipped: "no ETH price" };
+ const price = await quotePricer(usd);
+
// the season's swaps decide which tokens are in scope
- const swapRows = await db<{ chain_id: number; token: string; trader: string; is_buy: boolean; amount0: string; amount1: string; block_number: bigint; t: string }[]>`
- SELECT chain_id, token, trader, is_buy, amount0, amount1, block_number, block_time AS t
+ const swapRows = await read<{ chain_id: number; token: string; trader: string; is_buy: boolean; amount0: string; amount1: string; block_number: bigint; log_index: number; t: string }[]>`
+ SELECT chain_id, token, trader, is_buy, amount0, amount1, block_number, log_index, block_time AS t
FROM bb_launch_swaps WHERE block_time >= ${season.starts_at} AND block_time < ${until} AND trader IS NOT NULL`;
const keyOf = (cid: number, token: string) => `${chainKeyOf(cid) ?? cid}:${token}`;
const scope = new Map();
@@ -56,41 +68,17 @@ export async function computeSeason(db: Db, season: Season): Promise<{ wallets:
const cids = [...scope.values()].map((x) => x.cid);
const toks = [...scope.values()].map((x) => x.token);
- const [launchRows, firstRows, holderRows, boughtRows, linkedRows, profileRows, stateRows] = await Promise.all([
- db`
- SELECT l.chain_id, l.token, l.launcher, l.recipients, l.block_number, l.block_time, l.lp_fee, l.quote, l.sqrt_price_x96
- FROM bb_launches l JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON l.chain_id = u.cid AND l.token = u.tok`,
- db<{ chain_id: number; token: string; trader: string; block_number: bigint; t: string; amount0: string }[]>`
- SELECT DISTINCT ON (s.chain_id, s.token, s.trader) s.chain_id, s.token, s.trader, s.block_number, s.block_time AS t, s.amount0
- FROM bb_launch_swaps s JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON s.chain_id = u.cid AND s.token = u.tok
- WHERE s.is_buy AND s.trader IS NOT NULL ORDER BY s.chain_id, s.token, s.trader, s.block_number, s.log_index`,
- db<{ chain_id: number; token: string; holder: string; balance: string }[]>`
- SELECT h.chain_id, h.token, h.holder, h.balance
- FROM bb_token_holders h JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON h.chain_id = u.cid AND h.token = u.tok
- WHERE h.balance > 0`,
- db<{ chain_id: number; token: string; bought: string }[]>`
- SELECT s.chain_id, s.token, sum(s.amount1)::text AS bought
- FROM bb_launch_swaps s JOIN bb_launches l ON l.chain_id = s.chain_id AND l.token = s.token
- JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON s.chain_id = u.cid AND s.token = u.tok
- WHERE s.is_buy AND s.trader = l.launcher GROUP BY s.chain_id, s.token`,
- db<{ chain_id: number; token: string; to_addr: string }[]>`
- SELECT DISTINCT t.chain_id, t.token, t.to_addr
- FROM bb_token_transfers t JOIN bb_launches l ON l.chain_id = t.chain_id AND l.token = t.token
- JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON t.chain_id = u.cid AND t.token = u.tok
- WHERE t.from_addr = l.launcher OR t.from_addr IN (SELECT lower(r->>'payout') FROM jsonb_array_elements(l.recipients) r)`,
- db<{ wallet: string; x_status: string; x_account_created: string | null; x_followers: number | null; points_flag: string | null; hidden: boolean }[]>`
- SELECT wallet, x_status, x_account_created, x_followers, points_flag, hidden FROM bb_profiles WHERE deleted_at IS NULL`,
- db<{ chain_id: number; token: string; max_outside_holders: number }[]>`
- SELECT chain_id, token, max_outside_holders FROM bb_points_token_state WHERE season_id = ${season.id}`,
- ]);
-
- const usd = await ethUsd();
- const price = await quotePricer(usd);
- const launches: ScoreLaunch[] = launchRows.map((l) => {
+ const launchRows = await read`
+ SELECT l.chain_id, l.token, l.launcher, l.recipients, l.block_number, l.block_time, l.lp_fee, l.quote, l.sqrt_price_x96
+ FROM bb_launches l JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON l.chain_id = u.cid AND l.token = u.tok`;
+ const launches: ScoreLaunch[] = [];
+ for (const l of launchRows) {
const chain = chainKeyOf(l.chain_id) as ChainKey;
const q = price(chain, l.quote);
+ // a listed quote (ETH, TWIG, GITLAWB, USDG, a stock…) without a price right now = an outage: keep the last board
+ if (q.usd === null && q.key !== "other") return { skipped: `no price for ${q.key}` };
const perToken = l.sqrt_price_x96 ? quotePerToken(BigInt(l.sqrt_price_x96), q.decimals) : 0;
- return {
+ launches.push({
key: keyOf(l.chain_id, l.token),
launcher: l.launcher,
recipients: (Array.isArray(l.recipients) ? l.recipients : []).map((r) => String(r.payout).toLowerCase()),
@@ -100,11 +88,37 @@ export async function computeSeason(db: Db, season: Season): Promise<{ wallets:
quoteDecimals: q.decimals,
quoteUsd: q.usd,
tokenUsd: q.usd === null || !perToken ? null : perToken * q.usd,
- };
- });
+ });
+ }
+
+ const firstRows = await read<{ chain_id: number; token: string; trader: string; block_number: bigint; log_index: number; t: string; amount0: string; amount1: string }[]>`
+ SELECT DISTINCT ON (s.chain_id, s.token, s.trader) s.chain_id, s.token, s.trader, s.block_number, s.log_index, s.block_time AS t, s.amount0, s.amount1
+ FROM bb_launch_swaps s JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON s.chain_id = u.cid AND s.token = u.tok
+ WHERE s.is_buy AND s.trader IS NOT NULL ORDER BY s.chain_id, s.token, s.trader, s.block_number, s.log_index`;
+ // balances of wallets that ever bought the token (airdrop / dust recipients never matter to any rule)
+ const holderRows = await read<{ chain_id: number; token: string; holder: string; balance: string }[]>`
+ SELECT h.chain_id, h.token, h.holder, h.balance
+ FROM bb_token_holders h JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON h.chain_id = u.cid AND h.token = u.tok
+ WHERE h.balance > 0
+ AND EXISTS (SELECT 1 FROM bb_launch_swaps s WHERE s.trader = h.holder AND s.chain_id = h.chain_id AND s.token = h.token AND s.is_buy)`;
+ const boughtRows = await read<{ chain_id: number; token: string; bought: string }[]>`
+ SELECT s.chain_id, s.token, sum(s.amount1)::text AS bought
+ FROM bb_launch_swaps s JOIN bb_launches l ON l.chain_id = s.chain_id AND l.token = s.token
+ JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON s.chain_id = u.cid AND s.token = u.tok
+ WHERE s.is_buy AND s.trader = l.launcher GROUP BY s.chain_id, s.token`;
+ const linkedRows = await read<{ chain_id: number; token: string; to_addr: string }[]>`
+ SELECT DISTINCT t.chain_id, t.token, t.to_addr
+ FROM bb_token_transfers t JOIN bb_launches l ON l.chain_id = t.chain_id AND l.token = t.token
+ JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON t.chain_id = u.cid AND t.token = u.tok
+ WHERE t.from_addr = l.launcher OR t.from_addr IN (SELECT lower(r->>'payout') FROM jsonb_array_elements(l.recipients) r)`;
+ // every profile, deleted ones too: a flag must survive deleting the profile
+ const profileRows = await read<{ wallet: string; x_status: string; x_account_created: string | null; x_followers: number | null; points_flag: string | null; hidden: boolean; deleted_at: string | null }[]>`
+ SELECT wallet, x_status, x_account_created, x_followers, points_flag, hidden, deleted_at FROM bb_profiles`;
+
const abs = (v: string) => (v.startsWith("-") ? BigInt(v.slice(1)) : BigInt(v));
- const swaps: ScoreSwap[] = swapRows.map((s) => ({ key: keyOf(s.chain_id, s.token), trader: s.trader, isBuy: s.is_buy, quoteRaw: abs(s.amount0), tokenRaw: abs(s.amount1), block: Number(s.block_number), time: new Date(s.t).getTime() }));
- const firstBuys: FirstBuy[] = firstRows.map((f) => ({ key: keyOf(f.chain_id, f.token), wallet: f.trader, block: Number(f.block_number), time: new Date(f.t).getTime(), quoteRaw: abs(f.amount0) }));
+ const int = (v: string) => abs(v.split(".")[0]);
+ const swaps: ScoreSwap[] = swapRows.map((s) => ({ key: keyOf(s.chain_id, s.token), trader: s.trader, isBuy: s.is_buy, quoteRaw: abs(s.amount0), tokenRaw: abs(s.amount1), block: Number(s.block_number), logIndex: Number(s.log_index), time: new Date(s.t).getTime() }));
+ const firstBuys: FirstBuy[] = firstRows.map((f) => ({ key: keyOf(f.chain_id, f.token), wallet: f.trader, block: Number(f.block_number), logIndex: Number(f.log_index), time: new Date(f.t).getTime(), quoteRaw: abs(f.amount0), tokenRaw: abs(f.amount1) }));
const linked = new Map>();
for (const r of linkedRows) {
const k = keyOf(r.chain_id, r.token);
@@ -112,8 +126,7 @@ export async function computeSeason(db: Db, season: Season): Promise<{ wallets:
set.add(r.to_addr);
linked.set(k, set);
}
- const cfg = eligibilityConfig();
- const eligible = new Set(profileRows.filter((p) => pointsEligible(p, now, cfg)).map((p) => p.wallet));
+ const eligible = new Set(profileRows.filter((p) => isEligible(p, now)).map((p) => p.wallet));
const flagged = new Set(profileRows.filter((p) => p.points_flag).map((p) => p.wallet));
const system = new Set([ZERO_ADDR, DEAD_ADDR]);
for (const c of CHAIN_KEYS) {
@@ -131,13 +144,12 @@ export async function computeSeason(db: Db, season: Season): Promise<{ wallets:
launches,
swaps,
firstBuys,
- holders: holderRows.map((h) => ({ key: keyOf(h.chain_id, h.token), wallet: h.holder, balanceRaw: BigInt(h.balance.split(".")[0]) })),
- launcherBought: new Map(boughtRows.map((b) => [keyOf(b.chain_id, b.token), abs(b.bought.split(".")[0])])),
+ holders: holderRows.map((h) => ({ key: keyOf(h.chain_id, h.token), wallet: h.holder, balanceRaw: int(h.balance) })),
+ launcherBought: new Map(boughtRows.map((b) => [keyOf(b.chain_id, b.token), int(b.bought)])),
linked,
system,
eligible,
flagged,
- maxOutsideBefore: new Map(stateRows.map((s) => [keyOf(s.chain_id, s.token), Number(s.max_outside_holders)])),
});
const scores = [...result.wallets.values()];
@@ -153,9 +165,8 @@ export async function computeSeason(db: Db, season: Season): Promise<{ wallets:
rank_scout: rs.get(s.wallet) ?? null,
breakdown: { creator: s.creatorWhy, scout: s.scoutWhy },
}));
- const states = [...result.maxOutside.entries()].map(([k, n]) => ({ cid: scope.get(k)?.cid ?? 0, token: scope.get(k)?.token ?? "", n })).filter((x) => x.cid && x.n > 0);
- await db.begin(async (tx) => {
+ await write.begin(async (tx) => {
const t = tx as unknown as Db;
await t`DELETE FROM bb_points WHERE season_id = ${season.id}`;
for (let i = 0; i < rows.length; i += 1000) {
@@ -168,41 +179,27 @@ export async function computeSeason(db: Db, season: Season): Promise<{ wallets:
${part.map((r) => JSON.stringify(r.breakdown))}::text[])
AS u(w, c, s, tot, e, rc, rs, b)`; // booleans and nullable ints travel as text: the driver does not type those arrays
}
- if (states.length) {
- await t`
- INSERT INTO bb_points_token_state (season_id, chain_id, token, max_outside_holders)
- SELECT ${season.id}, u.cid, u.tok, u.n FROM unnest(${states.map((x) => x.cid)}::int[], ${states.map((x) => x.token)}::text[], ${states.map((x) => x.n)}::int[]) AS u(cid, tok, n)
- ON CONFLICT (season_id, chain_id, token) DO UPDATE SET max_outside_holders = GREATEST(bb_points_token_state.max_outside_holders, EXCLUDED.max_outside_holders)`;
- }
+ await t`UPDATE bb_seasons SET computed_at = now() WHERE id = ${season.id}`;
});
return { wallets: rows.length, eligible: rows.filter((r) => r.eligible).length, ms: Date.now() - t0 };
}
-let lastRun = 0;
+let running = false;
+let lastCheck = 0;
const EVERY_MS = 60 * 60_000;
-/** The hourly run, from the sync loop: one machine at a time (session advisory lock), at most hourly per season. */
-export async function computePointsIfDue(force = false): Promise<{ wallets: number; eligible: number; ms: number } | null> {
+/** Compute one season under the cross-machine lock (reads on the reserved connection, the replace on the pool). */
+async function computeLocked(season: Season): Promise {
const pool = maybeDb();
if (!pool) return null;
- if (!force && Date.now() - lastRun < 5 * 60_000) return null; // cheap check before touching the db
- lastRun = Date.now();
- const season = await currentSeason();
- if (!season) return null;
- if (!force) {
- const [r] = await pool<{ at: string | null }[]>`SELECT max(computed_at) AS at FROM bb_points WHERE season_id = ${season.id}`;
- // a finished season is computed once more after its end, then left as the final standings
- if (r?.at && (Date.now() - new Date(r.at).getTime() < EVERY_MS || new Date(r.at).getTime() > new Date(season.ends_at).getTime())) return null;
- }
- // the session lock lives on one reserved connection; the work itself (reads, the replace transaction) uses the pool
const reserved = await pool.reserve();
const db = reserved as unknown as Db;
try {
const [{ ok }] = await db<{ ok: boolean }[]>`SELECT pg_try_advisory_lock(hashtext('points-compute')) AS ok`;
if (!ok) return null;
try {
- const r = await computeSeason(pool, season);
- console.log(`[points] ${season.slug}: ${r.wallets} wallets (${r.eligible} eligible) in ${r.ms}ms`);
+ const r = await computeSeason(db, pool, season);
+ console.log(`[points] ${season.slug}:`, "skipped" in r ? `skipped (${r.skipped}), last board kept` : `${r.wallets} wallets (${r.eligible} eligible) in ${r.ms}ms`);
return r;
} finally {
await db`SELECT pg_advisory_unlock(hashtext('points-compute'))`.catch(() => {});
@@ -212,6 +209,33 @@ export async function computePointsIfDue(force = false): Promise<{ wallets: numb
}
}
+/**
+ * The hourly run, from the sync loop: at most hourly per season (bb_seasons.computed_at, so a season with no points
+ * yet does not recompute every few minutes), one machine at a time, never while a run is in progress on this machine.
+ * An ended season gets exactly one compute after its end, which becomes its final standings.
+ */
+export async function computePointsIfDue(): Promise {
+ if (running || Date.now() - lastCheck < 5 * 60_000) return null;
+ lastCheck = Date.now();
+ running = true;
+ try {
+ const season = await currentSeason();
+ if (!season || isFinal(season)) return null;
+ if (season.computed_at && Date.now() - new Date(season.computed_at).getTime() < EVERY_MS && !seasonEnded(season)) return null;
+ return await computeLocked(season);
+ } finally {
+ running = false;
+ }
+}
+
+/** Admin "recompute now": refused once a season's final standings exist. */
+export async function recomputeNow(): Promise {
+ const season = await currentSeason();
+ if (!season) return { error: "no season" };
+ if (isFinal(season)) return { error: `${season.name} has ended; its final standings stay as they are` };
+ return computeLocked(season);
+}
+
// ── read ─────────────────────────────────────────────────────────────────────
type PointsRaw = { wallet: string; creator: number; scout: number; total: number; eligible: boolean; rank_creator: number | null; rank_scout: number | null; breakdown: { creator: CreatorBreakdown; scout: ScoutBreakdown }; computed_at: string };
@@ -236,19 +260,41 @@ export async function walletPoints(seasonId: number, wallet: string): Promise {
+ const db = maybeDb();
+ if (!db || !isAddress(wallet)) return "no_profile";
+ const [p] = await db<{ x_status: string; x_account_created: string | null; x_followers: number | null; points_flag: string | null; hidden: boolean; deleted_at: string | null }[]>`
+ SELECT x_status, x_account_created, x_followers, points_flag, hidden, deleted_at FROM bb_profiles WHERE wallet = ${wallet.toLowerCase()}`;
+ return notEligibleReason(p ?? null, Date.now());
+}
+
// ── admin ────────────────────────────────────────────────────────────────────
export async function startSeason(days = 28): Promise {
const db = maybeDb()!;
const [{ n }] = await db<{ n: number }[]>`SELECT count(*)::int AS n FROM bb_seasons`;
const [s] = await db`
INSERT INTO bb_seasons (slug, name, starts_at, ends_at, public) VALUES (${`s${n + 1}`}, ${`Season ${n + 1}`}, now(), now() + make_interval(days => ${days}), false)
- RETURNING id, slug, name, starts_at, ends_at, public`;
+ RETURNING id, slug, name, starts_at, ends_at, public, computed_at`;
return s;
}
-export async function setSeasonPublic(id: number, value: boolean): Promise {
+/**
+ * Publishing starts the season for everyone: its clock restarts now with the same length, and the shadow run's points
+ * are dropped (they were for tuning). Hiding again keeps the clock.
+ */
+export async function publishSeason(id: number): Promise {
const db = maybeDb()!;
- await db`UPDATE bb_seasons SET public = ${value} WHERE id = ${id}`;
+ await db.begin(async (tx) => {
+ const t = tx as unknown as Db;
+ await t`UPDATE bb_seasons SET public = true, ends_at = now() + (ends_at - starts_at), starts_at = now(), computed_at = NULL WHERE id = ${id} AND NOT public`;
+ await t`DELETE FROM bb_points WHERE season_id = ${id}`;
+ });
+}
+
+export async function hideSeason(id: number): Promise {
+ const db = maybeDb()!;
+ await db`UPDATE bb_seasons SET public = false WHERE id = ${id}`;
}
export async function endSeasonNow(id: number): Promise {
@@ -256,6 +302,11 @@ export async function endSeasonNow(id: number): Promise {
await db`UPDATE bb_seasons SET ends_at = LEAST(ends_at, now()) WHERE id = ${id}`;
}
+/** The final standings of an ended season, before another one starts (so starting never skips them). */
+export async function finalizeIfEnded(season: Season): Promise {
+ if (seasonEnded(season) && !isFinal(season)) await computeLocked(season);
+}
+
/** The shadow view for admins: top wallets on each board, eligible or not, with why lines (the trial-week review). */
export async function previewBoards(seasonId: number, limit = 50): Promise<{ creator: (BoardRow & { eligible: boolean })[]; scout: (BoardRow & { eligible: boolean })[]; names: Record; computed_at: string | null; wallets: number; eligible: number }> {
const db = maybeDb();
@@ -263,7 +314,7 @@ export async function previewBoards(seasonId: number, limit = 50): Promise<{ cre
const [c, s, [meta]] = await Promise.all([
db`SELECT wallet, creator, scout, total, eligible, rank_creator, rank_scout, breakdown, computed_at FROM bb_points WHERE season_id = ${seasonId} AND creator > 0 ORDER BY creator DESC, wallet LIMIT ${limit}`,
db`SELECT wallet, creator, scout, total, eligible, rank_creator, rank_scout, breakdown, computed_at FROM bb_points WHERE season_id = ${seasonId} AND scout > 0 ORDER BY scout DESC, wallet LIMIT ${limit}`,
- db<{ at: string | null; n: number; e: number }[]>`SELECT max(computed_at) AS at, count(*)::int AS n, count(*) FILTER (WHERE eligible)::int AS e FROM bb_points WHERE season_id = ${seasonId}`,
+ db<{ at: string | null; n: number; e: number }[]>`SELECT (SELECT computed_at FROM bb_seasons WHERE id = ${seasonId}) AS at, count(*)::int AS n, count(*) FILTER (WHERE eligible)::int AS e FROM bb_points WHERE season_id = ${seasonId}`,
]);
const row = (board: Board) => (r: PointsRaw, i: number) => ({ rank: i + 1, wallet: r.wallet, points: board === "creator" ? r.creator : r.scout, eligible: r.eligible, why: whyLine(board, { creatorWhy: r.breakdown.creator, scoutWhy: r.breakdown.scout }) });
const creator = c.map(row("creator"));
From 7ca4371a4b4b2b0c4df3db62f1f1791f6e53c9b2 Mon Sep 17 00:00:00 2001
From: Kevin Codex
Date: Wed, 7 Oct 2026 16:33:01 +0800
Subject: [PATCH 03/13] fix(points): review round 2 (net buyers only for fee
points, publish once, public season reads)
- fee points (both boards) count only on BUYS by traders who still hold
half of what they bought of that token this season: a round trip,
whose fees go back to a farmer's own token, earns nothing, so washing
ties up capital that grows with volume; scout fee points also need a
token with 5+ eligible real holders
- a holder is placed on their best token among the ones that count
(best 3 per launch day), so a cut token no longer loses its holders
- publish: only the FIRST publish starts the clock and drops the shadow
points (published_at); showing the boards again after hiding, or an
ended season, changes neither
- the price abort is limited to ETH / GITLAWB / TWIG; any other unpriced
quote only leaves its own tokens out of that run
- final standings = a compute whose data cut-off (computed_until) is at
or past the end; starting a season refuses (409) until the previous
one is final
- public pages and APIs read the latest PUBLIC season, so an ended
season keeps showing its final standings during the next shadow run
- /api/points never says whether a moderator hid or kept off a wallet
- schema: ALTER ... ADD COLUMN IF NOT EXISTS for computed_at,
computed_until, published_at; copy for ended seasons; rules wording
says "qualifying profile" and spells out net buying
---
app/db/schema.sql | 4 ++
app/src/app/api/points/admin/route.ts | 11 ++--
app/src/app/api/points/board/route.ts | 6 +-
app/src/app/api/points/route.ts | 10 ++--
app/src/app/leaderboard/page.tsx | 6 +-
app/src/app/rules/page.tsx | 6 +-
app/src/app/u/[username]/page.tsx | 6 +-
app/src/components/points/PointsAdmin.tsx | 4 +-
app/src/components/points/YourStanding.tsx | 3 +-
app/src/lib/points/score.test.ts | 32 +++++++----
app/src/lib/points/score.ts | 52 +++++++++++++----
app/src/lib/points/server.ts | 67 ++++++++++++++++------
12 files changed, 141 insertions(+), 66 deletions(-)
diff --git a/app/db/schema.sql b/app/db/schema.sql
index 83f2752e..9a3e5448 100644
--- a/app/db/schema.sql
+++ b/app/db/schema.sql
@@ -423,5 +423,9 @@ CREATE TABLE IF NOT EXISTS bb_points (
computed_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (season_id, wallet)
);
+-- added after the table was first written (a database built from the first draft lacks them)
+ALTER TABLE bb_seasons ADD COLUMN IF NOT EXISTS computed_at timestamptz;
+ALTER TABLE bb_seasons ADD COLUMN IF NOT EXISTS computed_until timestamptz; -- the data cut-off of the last compute
+ALTER TABLE bb_seasons ADD COLUMN IF NOT EXISTS published_at timestamptz; -- first publish: the season's clock starts here
CREATE INDEX IF NOT EXISTS bb_points_creator_idx ON bb_points (season_id, rank_creator) WHERE rank_creator IS NOT NULL;
CREATE INDEX IF NOT EXISTS bb_points_scout_idx ON bb_points (season_id, rank_scout) WHERE rank_scout IS NOT NULL;
diff --git a/app/src/app/api/points/admin/route.ts b/app/src/app/api/points/admin/route.ts
index f533a5de..f6256182 100644
--- a/app/src/app/api/points/admin/route.ts
+++ b/app/src/app/api/points/admin/route.ts
@@ -25,17 +25,18 @@ export async function POST(req: Request) {
switch (action) {
case "start": {
if (season && !seasonEnded(season)) return NextResponse.json({ error: `${season.name} is still running` }, { status: 409 });
- if (season) await finalizeIfEnded(season);
+ if (season && !(await finalizeIfEnded(season))) return NextResponse.json({ error: `${season.name}'s final standings could not be computed yet; try again in a minute` }, { status: 409 });
const days = Math.min(90, Math.max(1, Math.trunc(Number(b.days) || 28)));
const s = await startSeason(days);
void recomputeNow().catch(() => {});
return NextResponse.json({ ok: true, season: s });
}
- case "publish":
+ case "publish": {
if (!season) return NextResponse.json({ error: "no season" }, { status: 404 });
- await publishSeason(season.id);
- void recomputeNow().catch(() => {});
- return NextResponse.json({ ok: true });
+ const r = await publishSeason(season.id);
+ if (r === "started") void recomputeNow().catch(() => {});
+ return NextResponse.json({ ok: true, result: r });
+ }
case "unpublish":
if (!season) return NextResponse.json({ error: "no season" }, { status: 404 });
await hideSeason(season.id);
diff --git a/app/src/app/api/points/board/route.ts b/app/src/app/api/points/board/route.ts
index 899c5875..8d723df6 100644
--- a/app/src/app/api/points/board/route.ts
+++ b/app/src/app/api/points/board/route.ts
@@ -1,6 +1,6 @@
import { NextResponse } from "next/server";
import { memo } from "@/lib/launchpad/memo";
-import { boardRows, currentSeason } from "@/lib/points/server";
+import { boardRows, publicSeason } from "@/lib/points/server";
export const dynamic = "force-dynamic";
@@ -8,8 +8,8 @@ export const dynamic = "force-dynamic";
export async function GET(req: Request) {
const board = new URL(req.url).searchParams.get("board") === "scout" ? "scout" : "creator";
try {
- const season = await memo("points:season", 10_000, currentSeason);
- if (!season?.public) return NextResponse.json({ error: "no public season" }, { status: 404 });
+ const season = await memo("points:public-season", 10_000, publicSeason);
+ if (!season) return NextResponse.json({ error: "no public season" }, { status: 404 });
const data = await memo(`points:board:${season.id}:${board}`, 30_000, () => boardRows(season.id, board));
return NextResponse.json({ season: { name: season.name, ends_at: season.ends_at }, board, ...data }, { headers: { "cache-control": "no-store" } });
} catch (err) {
diff --git a/app/src/app/api/points/route.ts b/app/src/app/api/points/route.ts
index 99d85e81..cf3e3d20 100644
--- a/app/src/app/api/points/route.ts
+++ b/app/src/app/api/points/route.ts
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { isAddress } from "viem";
import { memo } from "@/lib/launchpad/memo";
-import { currentSeason, seasonEnded, walletPoints, walletReason } from "@/lib/points/server";
+import { publicSeason, seasonEnded, walletPoints, walletReason } from "@/lib/points/server";
export const dynamic = "force-dynamic";
@@ -12,9 +12,11 @@ export const dynamic = "force-dynamic";
export async function GET(req: Request) {
const w = (new URL(req.url).searchParams.get("wallet") ?? "").toLowerCase();
try {
- const season = await memo("points:season", 10_000, currentSeason);
- if (!season?.public) return NextResponse.json({ season: null, me: null, reason: null }, { headers: { "cache-control": "no-store" } });
- const [me, reason] = isAddress(w) ? await Promise.all([memo(`points:me:${season.id}:${w}`, 10_000, () => walletPoints(season.id, w)), memo(`points:why:${w}`, 10_000, () => walletReason(w))]) : [null, null];
+ const season = await memo("points:public-season", 10_000, publicSeason);
+ if (!season) return NextResponse.json({ season: null, me: null, reason: null }, { headers: { "cache-control": "no-store" } });
+ const [me, raw] = isAddress(w) ? await Promise.all([memo(`points:me:${season.id}:${w}`, 10_000, () => walletPoints(season.id, w)), memo(`points:why:${w}`, 10_000, () => walletReason(w))]) : [null, null];
+ // anyone can ask about any wallet: never say whether a moderator hid it or kept it off the boards
+ const reason = raw === "kept_off" || raw === "hidden" ? null : raw;
return NextResponse.json({ season: { name: season.name, starts_at: season.starts_at, ends_at: season.ends_at, ended: seasonEnded(season) }, me, reason }, { headers: { "cache-control": "no-store" } });
} catch (err) {
console.error("[points] read failed:", err instanceof Error ? err.message : err);
diff --git a/app/src/app/leaderboard/page.tsx b/app/src/app/leaderboard/page.tsx
index 050a4078..3e180307 100644
--- a/app/src/app/leaderboard/page.tsx
+++ b/app/src/app/leaderboard/page.tsx
@@ -6,7 +6,7 @@ import NamesProvider from "@/components/profile/NamesProvider";
import { WhoAvatar, WhoName } from "@/components/profile/Who";
import YourStanding from "@/components/points/YourStanding";
import { memo } from "@/lib/launchpad/memo";
-import { boardRows, currentSeason, seasonEnded, type Board } from "@/lib/points/server";
+import { boardRows, publicSeason, seasonEnded, type Board } from "@/lib/points/server";
import { pageMetadata } from "@/lib/seo";
export const dynamic = "force-dynamic";
@@ -19,9 +19,9 @@ function daysLeft(endsAt: string): number {
export default async function LeaderboardPage({ searchParams }: { searchParams: Promise<{ board?: string }> }) {
const { board: raw } = await searchParams;
const board: Board = raw === "scout" ? "scout" : "creator";
- const season = await memo("points:season", 10_000, currentSeason);
+ const season = await memo("points:public-season", 10_000, publicSeason);
- if (!season?.public) {
+ if (!season) {
return (
diff --git a/app/src/app/rules/page.tsx b/app/src/app/rules/page.tsx
index 8f5478ba..9f82752f 100644
--- a/app/src/app/rules/page.tsx
+++ b/app/src/app/rules/page.tsx
@@ -117,9 +117,9 @@ export default function RulesPage() {
Season points
Points for real buyers. Nothing for noise.
See the leaderboard
Points measure reputation on openlaunch. They have no cash value, are not a token and not a promise of one, cannot be transferred or sold, and the rules can change between seasons. Wallets that game the system lose their points. Everything is computed from the chain every hour; the rules below are the whole of it.
- What counts as a real buyer?
A wallet whose first buy of a token was ${RULES.minTradeUsd} or more, made after the launch block, the next {RULES.sniperBlocks} blocks and the first {RULES.sniperMs / 1000} seconds. Never the launcher, a fee recipient, a wallet that got the token by transfer from either, or a wallet kept off points. A real holder is a real buyer still holding ${RULES.holderMinUsd} or more (${RULES.verifiedHolderMinUsd} for a verified profile). Only tokens priced in a quote we can price count.
- How do creators earn?
On any token you launched, only what happens during the season counts. Each real holder who first bought this season and still holds a day later earns you {RULES.verifiedHolder} points if their profile qualifies (below), or {RULES.holder} if not; a week later, half again. Each holder counts once for you, on whichever of your tokens they are worth most.
Trading fees from verified traders' trades earn {RULES.creatorFeePointsPerUsd} points per $1, up to {RULES.creatorFeeCapPerTraderDay} from one trader a day. Of the tokens you launch on one day, only your best {RULES.bestTokensPerDay} count. A token scores nothing if you sold during the season and now keep less than half of what you bought of it.
- How do traders earn?
Be one of the first {RULES.earlyFirstBuyers} real buyers of a token that now has {RULES.earlyHoldersTarget} real holders: {RULES.early} points, and {RULES.earlyStillHolding} more while you still hold half of that first buy.
Every token you first buy this season and still half-hold a day later: {RULES.hold} points, up to {RULES.holdTokensPerDay} tokens a day. Fees you pay on other people's tokens with {RULES.scoutFeeMinHolders}+ real holders: {RULES.scoutFeePointsPerUsd} points per $1, up to {RULES.scoutFeeCapPerDay} a day. Trades under ${RULES.minTradeUsd} earn nothing.
+ What counts as a real buyer?
A wallet whose first buy of a token was ${RULES.minTradeUsd} or more, made after the launch block, the next {RULES.sniperBlocks} blocks and the first {RULES.sniperMs / 1000} seconds. Never the launcher, a fee recipient, a wallet that got the token by transfer from either, or a wallet kept off points. A real holder is a real buyer still holding ${RULES.holderMinUsd} or more (${RULES.verifiedHolderMinUsd} for a qualifying profile, below). Only tokens priced in a quote we can price count.
+ How do creators earn?
On any token you launched, only what happens during the season counts. Each real holder who first bought this season and still holds a day later earns you {RULES.verifiedHolder} points if their profile qualifies (below), or {RULES.holder} if not; a week later, half again. Each holder counts once for you, on whichever of your tokens they are worth most.
Fees on buys by traders with a qualifying profile earn {RULES.creatorFeePointsPerUsd} points per $1 while they still hold half of what they bought this season (a buy and sell back earns nothing), up to {RULES.creatorFeeCapPerTraderDay} from one trader a day. Of the tokens you launch on one day, only your best {RULES.bestTokensPerDay} count. A token scores nothing if you sold during the season and now keep less than half of what you bought of it.
+ How do traders earn?
Be one of the first {RULES.earlyFirstBuyers} real buyers of a token that now has {RULES.earlyHoldersTarget} real holders, with your first buy this season: {RULES.early} points, and {RULES.earlyStillHolding} more while you still hold half of that first buy.
Every token you first buy this season and still half-hold a day later: {RULES.hold} points, up to {RULES.holdTokensPerDay} tokens a day. Fees you pay buying other people's tokens with {RULES.scoutFeeMinHolders}+ real holders ({RULES.scoutFeeMinEligibleHolders}+ of them qualifying), while you still hold half of what you bought: {RULES.scoutFeePointsPerUsd} points per $1, up to {RULES.scoutFeeCapPerDay} a day. Trades under ${RULES.minTradeUsd} earn nothing.
Who appears on the leaderboard?
Profiles verified with one post on X from an account at least {RULES.eligibleMinAgeDays} days old with {RULES.eligibleMinFollowers} or more followers. Everyone else can see the points waiting for them on their dashboard; they unlock, for the whole season so far, within the hour after the profile qualifies. The post itself earns nothing.
diff --git a/app/src/app/u/[username]/page.tsx b/app/src/app/u/[username]/page.tsx
index b280b23b..0bb62a15 100644
--- a/app/src/app/u/[username]/page.tsx
+++ b/app/src/app/u/[username]/page.tsx
@@ -18,7 +18,7 @@ import { fmtUsd } from "@/lib/launchpad/math";
import { ago, nowMs } from "@/lib/launchpad/time";
import { CHAIN_SHORT, SITE_URL, explorerTx } from "@/lib/chainPublic";
import { BRAND_DOMAIN, BRAND_X } from "@/lib/brand";
-import { currentSeason, walletPoints } from "@/lib/points/server";
+import { publicSeason, walletPoints } from "@/lib/points/server";
import { Trophy } from "lucide-react";
export const dynamic = "force-dynamic";
@@ -63,8 +63,8 @@ export default async function ProfilePage({ params }: { params: Promise<{ userna
const ownName = { u: p.username, d: p.display_name, a: p.avatar_url, v: p.x_state === "verified" };
// season points, once the season is public, for a wallet that qualifies
// fails soft: a points hiccup never takes the profile page down
- const season = await memo("points:season", 10_000, currentSeason).catch(() => null);
- const pts = season?.public ? await memo(`points:me:${season.id}:${p.wallet}`, 10_000, () => walletPoints(season.id, p.wallet)).catch(() => null) : null;
+ const season = await memo("points:public-season", 10_000, publicSeason).catch(() => null);
+ const pts = season ? await memo(`points:me:${season.id}:${p.wallet}`, 10_000, () => walletPoints(season.id, p.wallet)).catch(() => null) : null;
const showPts = Boolean(season && pts && pts.eligible && pts.total > 0);
const now = nowMs();
const joined = new Date(p.created_at).toLocaleDateString("en-US", { month: "short", year: "numeric", timeZone: "UTC" });
diff --git a/app/src/components/points/PointsAdmin.tsx b/app/src/components/points/PointsAdmin.tsx
index e400aeb7..830ffa2f 100644
--- a/app/src/components/points/PointsAdmin.tsx
+++ b/app/src/components/points/PointsAdmin.tsx
@@ -12,7 +12,7 @@ import { nowMs } from "@/lib/launchpad/time";
import { friendlyError } from "@/lib/errors";
type Row = { rank: number; wallet: string; points: number; eligible: boolean; why: string };
-type Preview = { season: { name: string; starts_at: string; ends_at: string; public: boolean } | null; creator?: Row[]; scout?: Row[]; names?: Record; computed_at?: string | null; wallets?: number; eligible?: number; at: number };
+type Preview = { season: { name: string; starts_at: string; ends_at: string; public: boolean; published_at: string | null } | null; creator?: Row[]; scout?: Row[]; names?: Record; computed_at?: string | null; wallets?: number; eligible?: number; at: number };
function nonce(): string {
const b = new Uint8Array(16);
@@ -102,7 +102,7 @@ export default function PointsAdmin() {
)}
{me.eligible ? (rank ? <>Rank #{rank}> : "Not ranked on this board yet") : <>Your points are waiting. See what unlocks them.>}{why ? · {why} : null}
+
{me.eligible ? (rank ? <>Rank #{rank}> : "Not ranked on this board") : ended ? "Not on the final board" : <>Your points are waiting. See what unlocks them.>}{why ? · {why} : null}
{points.toLocaleString("en-US")}
diff --git a/app/src/lib/points/score.test.ts b/app/src/lib/points/score.test.ts
index 3ab5b30e..356c97c5 100644
--- a/app/src/lib/points/score.test.ts
+++ b/app/src/lib/points/score.test.ts
@@ -117,14 +117,21 @@ test("holds: first buy ≥ $5 this season, half still held a day later; 20 token
assert.equal(soldDown.wallets.get(w(2))?.scoutWhy.holds ?? 0, 0, "sold down to 40%: no hold");
});
-test("scout fees: 20+ real holders, ≥ $5 trades, at most 200 a day across all tokens", () => {
+test("scout fees: net buyers on tokens with 20+ real holders (5+ eligible), at most 200 a day; round trips earn nothing", () => {
const holders = Array.from({ length: 20 }, (_, i) => buyer(w(100 + i), START + DAY, 0.003));
- const trades: ScoreSwap[] = Array.from({ length: 60 }, (_, i) => ({ key: L.key, trader: w(1), isBuy: i % 2 === 0, quoteRaw: eth(1), tokenRaw: E18, block: 6000 + i, logIndex: i, time: START + 2 * DAY + i }));
- const r = scoreSeason(build(holders, { swaps: [...holders.map((h) => h.swap), ...trades] }));
- assert.equal(Math.round(r.wallets.get(w(1))!.scoutWhy.feesUsd * 10) <= RULES.scoutFeeCapPerDay * 10, true);
- assert.equal(r.wallets.get(w(1))!.scout, RULES.scoutFeeCapPerDay);
- const thin = scoreSeason(input({ swaps: trades }));
- assert.equal(thin.wallets.get(w(1))?.scout ?? 0, 0, "no real holders: no scout fee points");
+ const eligible = new Set(holders.slice(0, 5).map((h) => h.first.wallet));
+ // w(1) buys 1 ETH 30 times and keeps all of it: $20 fees each → capped at 200 points that day
+ const buys: ScoreSwap[] = Array.from({ length: 30 }, (_, i) => ({ key: L.key, trader: w(1), isBuy: true, quoteRaw: eth(1), tokenRaw: tokensFor(1), block: 6000 + i, logIndex: i, time: START + 2 * DAY + i }));
+ const kept = { key: L.key, wallet: w(1), balanceRaw: tokensFor(1) * 30n };
+ const base = { swaps: [...holders.map((h) => h.swap), ...buys], firstBuys: holders.map((h) => h.first), holders: [...holders.map((h) => h.holder), kept], eligible };
+ assert.equal(scoreSeason(input(base)).wallets.get(w(1))!.scoutWhy.feesUsd > 0, true);
+ assert.equal(scoreSeason(input(base)).wallets.get(w(1))!.scout <= RULES.scoutFeeCapPerDay + RULES.hold, true);
+ const roundTrip = scoreSeason(input({ ...base, holders: holders.map((h) => h.holder) })); // sold it all again
+ assert.equal(roundTrip.wallets.get(w(1))?.scoutWhy.feesUsd ?? 0, 0, "a round trip earns no fee points");
+ const fewEligible = scoreSeason(input({ ...base, eligible: new Set() }));
+ assert.equal(fewEligible.wallets.get(w(1))?.scoutWhy.feesUsd ?? 0, 0, "a token without 5 eligible holders earns no scout fees");
+ const thin = scoreSeason(input({ swaps: buys, holders: [kept] }));
+ assert.equal(thin.wallets.get(w(1))?.scoutWhy.feesUsd ?? 0, 0, "no real holders: no scout fee points");
});
// ── the exploits the review found, as regressions ────────────────────────────
@@ -144,11 +151,14 @@ test("exploit: dust sent to 50 wallets makes none of them holders and unlocks no
assert.equal(r.wallets.get(w(1))?.scoutWhy.holds ?? 0, 0);
});
-test("exploit: wash trading by an unverified wallet earns the creator nothing; by a verified one, at most 50 a day", () => {
- const wash: ScoreSwap[] = Array.from({ length: 100 }, (_, i) => ({ key: L.key, trader: "0xpuppet", isBuy: i % 2 === 0, quoteRaw: eth(10), tokenRaw: E18, block: 1000 + i, logIndex: i, time: START + 2 * DAY + i * 1000 }));
+test("exploit: wash trading earns nothing, verified or not; a verified net buyer gives the creator at most 50 a day", () => {
+ const wash: ScoreSwap[] = Array.from({ length: 100 }, (_, i) => ({ key: L.key, trader: "0xpuppet", isBuy: i % 2 === 0, quoteRaw: eth(10), tokenRaw: tokensFor(10), block: 1000 + i, logIndex: i, time: START + 2 * DAY + i * 1000 }));
assert.equal(scoreSeason(input({ swaps: wash })).wallets.get(CREATOR)?.creator ?? 0, 0);
- const capped = scoreSeason(input({ swaps: wash, eligible: new Set(["0xpuppet"]) }));
- assert.equal(capped.wallets.get(CREATOR)!.creator, RULES.creatorFeeCapPerTraderDay);
+ assert.equal(scoreSeason(input({ swaps: wash, eligible: new Set(["0xpuppet"]) })).wallets.get(CREATOR)?.creator ?? 0, 0, "round trips by a verified wallet: the fees come back, no points");
+ const buys = wash.filter((x) => x.isBuy);
+ const holding = { key: L.key, wallet: "0xpuppet", balanceRaw: tokensFor(10) * 50n };
+ const net = scoreSeason(input({ swaps: buys, holders: [holding], eligible: new Set(["0xpuppet"]) }));
+ assert.equal(net.wallets.get(CREATOR)!.creator, RULES.creatorFeeCapPerTraderDay, "$500 of buys still held: capital at stake, capped");
});
test("exploit: a wallet kept off points (deleted profile or not) scores nothing and counts for no one", () => {
diff --git a/app/src/lib/points/score.ts b/app/src/lib/points/score.ts
index 99561bfb..266d9a2d 100644
--- a/app/src/lib/points/score.ts
+++ b/app/src/lib/points/score.ts
@@ -14,14 +14,16 @@
* Creator (any token the wallet launched; only activity inside the season counts)
* - each real holder whose first buy was in the season, a day or more ago: 30 if their profile is points-eligible,
* else 5; a week or more ago: half again. A holder counts once per creator (their best token).
- * - 10 per $1 of fees from trades ≥ $5 by points-eligible outside traders; at most 50 per trader per creator per day
+ * - 10 per $1 of fees on buys ≥ $5 by points-eligible outside traders who still hold half of what they bought this
+ season (net buyers: a round trip earns nothing); at most 50 per trader per creator per day
* - a token scores 0 if its creator sold during the season and now keeps less than half of what they bought
* - of the tokens a wallet launched on one UTC day, only the best 3 count
* Scout (traders)
* - one of the first 25 real buyers of a token that now has 50+ real holders, buying in the season: 100, and 50
* more while still holding half of that first buy
* - 5 per token first bought in the season (≥ $5) and still half-held a day later; at most 20 tokens per UTC day
- * - 10 per $1 of fees paid (trades ≥ $5) on other people's tokens with 20+ real holders; at most 200 a day
+ * - 10 per $1 of fees paid on buys ≥ $5 of other people's tokens with 20+ real holders (5+ of them eligible), while
+ still holding half of what you bought of it this season; at most 200 a day
*
* Only points-eligible wallets rank; everyone else sees what is waiting for them.
*/
@@ -48,6 +50,7 @@ export const RULES = {
scoutFeePointsPerUsd: 10,
scoutFeeCapPerDay: 200,
scoutFeeMinHolders: 20,
+ scoutFeeMinEligibleHolders: 5,
// points-eligible profile: X-verified on an account this old with this many followers
eligibleMinAgeDays: 30,
eligibleMinFollowers: 20,
@@ -153,6 +156,14 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
outside.push(s);
}
+ // net buyers: a trader's buys of a token earn fee points only while they hold half of what they bought this season,
+ // so a round trip (the fees coming back to a farmer's own token) earns nothing and washing ties up real capital
+ const seasonBought = new Map();
+ for (const s of outside) if (s.isBuy) seasonBought.set(`${s.key}|${s.trader}`, (seasonBought.get(`${s.key}|${s.trader}`) ?? 0n) + s.tokenRaw);
+ const netBuyer = (key: string, w: string) => halfHeld(key, w, seasonBought.get(`${key}|${w}`) ?? 0n);
+ const eligibleHolders = new Map();
+ for (const [key, list] of realHolders) eligibleHolders.set(key, list.filter((f) => input.eligible.has(f.wallet)).length);
+
// ── creator points ──
type TokenCreator = { l: ScoreLaunch; holders: { wallet: string; pts: number; eligible: boolean }[]; fees: { trader: string; day: number; usd: number }[]; dumped: boolean };
const perToken = new Map();
@@ -174,7 +185,8 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
}
}
for (const s of outside) {
- if (!input.eligible.has(s.trader)) continue; // fees count only from points-eligible traders: wash trades need real accounts
+ // fees count only on buys by points-eligible traders who are net buyers: wash trades need real accounts and capital
+ if (!s.isBuy || !input.eligible.has(s.trader) || !netBuyer(s.key, s.trader)) continue;
const l = launches.get(s.key)!;
const fee = (quoteUsd(l, s.quoteRaw) * l.lpFee) / 1_000_000;
if (fee > 0) ensure(l).fees.push({ trader: s.trader, day: utcDay(s.time), usd: fee });
@@ -197,19 +209,34 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
const value = new Map();
const counted = new Set();
const feeUsed = new Map();
- // holders: assign each holder to the token where they are worth the most (ties: first)
- const holderBest = new Map();
- for (const t of tokens) {
- if (t.dumped) continue;
- for (const h of t.holders) {
- const cur = holderBest.get(h.wallet);
- if (!cur || h.pts > cur.pts) holderBest.set(h.wallet, { t, pts: h.pts, eligible: h.eligible });
+ // holders: each holder once, on the token where they are worth the most. Two passes: pick each launch day's best
+ // tokens with every holder on their best token, then put each holder on their best token among those that count,
+ // so a holder whose favourite token was cut still counts on another
+ const assign = (allowed: (t: TokenCreator) => boolean) => {
+ const best = new Map();
+ for (const t of tokens) {
+ if (t.dumped || !allowed(t)) continue;
+ for (const h of t.holders) {
+ const cur = best.get(h.wallet);
+ if (!cur || h.pts > cur.pts) best.set(h.wallet, { t, pts: h.pts, eligible: h.eligible });
+ }
}
+ return best;
+ };
+ const firstPass = new Map();
+ for (const [, h] of assign(() => true)) firstPass.set(h.t, (firstPass.get(h.t) ?? 0) + h.pts);
+ const kept = new Set();
+ const daysFirst = new Map();
+ for (const t of tokens) {
+ const d = utcDay(t.l.launchTime);
+ daysFirst.set(d, [...(daysFirst.get(d) ?? []), t]);
}
+ for (const list of daysFirst.values()) for (const t of [...list].sort((a, b) => (firstPass.get(b) ?? 0) + b.fees.length - ((firstPass.get(a) ?? 0) + a.fees.length)).slice(0, RULES.bestTokensPerDay)) kept.add(t);
+ const holderBest = assign((t) => kept.has(t));
for (const [, h] of holderBest) value.set(h.t, (value.get(h.t) ?? 0) + h.pts);
const feeUsd = new Map();
for (const t of tokens) {
- if (t.dumped) continue;
+ if (t.dumped || !kept.has(t)) continue;
for (const f of t.fees) {
const k = `${f.trader}|${f.day}`;
const used = feeUsed.get(k) ?? 0;
@@ -276,7 +303,8 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
}
const scoutFeeUsed = new Map();
for (const s of outside) {
- if (holderCount(s.key) < RULES.scoutFeeMinHolders) continue;
+ if (!s.isBuy || !netBuyer(s.key, s.trader)) continue;
+ if (holderCount(s.key) < RULES.scoutFeeMinHolders || (eligibleHolders.get(s.key) ?? 0) < RULES.scoutFeeMinEligibleHolders) continue;
const l = launches.get(s.key)!;
const fee = (quoteUsd(l, s.quoteRaw) * l.lpFee) / 1_000_000;
if (fee <= 0) continue;
diff --git a/app/src/lib/points/server.ts b/app/src/lib/points/server.ts
index 694e7753..5d0c5817 100644
--- a/app/src/lib/points/server.ts
+++ b/app/src/lib/points/server.ts
@@ -18,16 +18,24 @@ import { isEligible, notEligibleReason, rankBy, scoreSeason, whyLine, type Creat
* season on the next run. A run whose prices are incomplete writes nothing: the last board stays.
*/
-export type Season = { id: number; slug: string; name: string; starts_at: string; ends_at: string; public: boolean; computed_at: string | null };
+export type Season = { id: number; slug: string; name: string; starts_at: string; ends_at: string; public: boolean; computed_at: string | null; computed_until: string | null; published_at: string | null };
export type Board = "creator" | "scout";
export type BoardRow = { rank: number; wallet: string; points: number; why: string };
export type WalletPoints = { creator: number; scout: number; total: number; eligible: boolean; rank_creator: number | null; rank_scout: number | null; why_creator: string; why_scout: string; computed_at: string };
-/** The current season: the latest one that has started (a finished one stays as the final standings until the next). */
+/** The current season: the latest one that has started, public or not (the admin's and the compute's season). */
export async function currentSeason(): Promise {
const db = maybeDb();
if (!db) return null;
- const [s] = await db`SELECT id, slug, name, starts_at, ends_at, public, computed_at FROM bb_seasons WHERE starts_at <= now() ORDER BY starts_at DESC LIMIT 1`;
+ const [s] = await db`SELECT id, slug, name, starts_at, ends_at, public, computed_at, computed_until, published_at FROM bb_seasons WHERE starts_at <= now() ORDER BY starts_at DESC LIMIT 1`;
+ return s ?? null;
+}
+
+/** The season everyone sees: the latest public one (an ended one keeps showing its final standings while the next is in its shadow run). */
+export async function publicSeason(): Promise {
+ const db = maybeDb();
+ if (!db) return null;
+ const [s] = await db`SELECT id, slug, name, starts_at, ends_at, public, computed_at, computed_until, published_at FROM bb_seasons WHERE public AND starts_at <= now() ORDER BY starts_at DESC LIMIT 1`;
return s ?? null;
}
@@ -35,9 +43,9 @@ export function seasonEnded(s: Pick, now = Date.now()): boole
return new Date(s.ends_at).getTime() <= now;
}
-/** The final standings are fixed once a compute has run after the season's end. */
+/** The final standings are fixed once a compute has read the data through the season's end. */
function isFinal(s: Season): boolean {
- return Boolean(s.computed_at && seasonEnded(s) && new Date(s.computed_at).getTime() > new Date(s.ends_at).getTime());
+ return Boolean(s.computed_until && new Date(s.computed_until).getTime() >= new Date(s.ends_at).getTime());
}
// ── compute ──────────────────────────────────────────────────────────────────
@@ -68,6 +76,10 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis
const cids = [...scope.values()].map((x) => x.cid);
const toks = [...scope.values()].map((x) => x.token);
+ // ETH, GITLAWB and TWIG price most of the board: without them the run would be wrong everywhere, so it writes
+ // nothing. Any other quote without a price (a stock, MUSEWORLD) only takes its own tokens out of this run.
+ const MAIN_QUOTES = new Set(["eth", "gitlawb", "twig"]);
+ let dropped = 0;
const launchRows = await read`
SELECT l.chain_id, l.token, l.launcher, l.recipients, l.block_number, l.block_time, l.lp_fee, l.quote, l.sqrt_price_x96
FROM bb_launches l JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON l.chain_id = u.cid AND l.token = u.tok`;
@@ -75,8 +87,11 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis
for (const l of launchRows) {
const chain = chainKeyOf(l.chain_id) as ChainKey;
const q = price(chain, l.quote);
- // a listed quote (ETH, TWIG, GITLAWB, USDG, a stock…) without a price right now = an outage: keep the last board
- if (q.usd === null && q.key !== "other") return { skipped: `no price for ${q.key}` };
+ if (q.usd === null && MAIN_QUOTES.has(q.key)) return { skipped: `no price for ${q.key}` };
+ if (q.usd === null) {
+ dropped++;
+ continue;
+ }
const perToken = l.sqrt_price_x96 ? quotePerToken(BigInt(l.sqrt_price_x96), q.decimals) : 0;
launches.push({
key: keyOf(l.chain_id, l.token),
@@ -179,8 +194,9 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis
${part.map((r) => JSON.stringify(r.breakdown))}::text[])
AS u(w, c, s, tot, e, rc, rs, b)`; // booleans and nullable ints travel as text: the driver does not type those arrays
}
- await t`UPDATE bb_seasons SET computed_at = now() WHERE id = ${season.id}`;
+ await t`UPDATE bb_seasons SET computed_at = now(), computed_until = ${until} WHERE id = ${season.id}`;
});
+ if (dropped) console.log(`[points] ${season.slug}: ${dropped} token(s) without a price left out of this run`);
return { wallets: rows.length, eligible: rows.filter((r) => r.eligible).length, ms: Date.now() - t0 };
}
@@ -275,21 +291,29 @@ export async function startSeason(days = 28): Promise {
const [{ n }] = await db<{ n: number }[]>`SELECT count(*)::int AS n FROM bb_seasons`;
const [s] = await db`
INSERT INTO bb_seasons (slug, name, starts_at, ends_at, public) VALUES (${`s${n + 1}`}, ${`Season ${n + 1}`}, now(), now() + make_interval(days => ${days}), false)
- RETURNING id, slug, name, starts_at, ends_at, public, computed_at`;
+ RETURNING id, slug, name, starts_at, ends_at, public, computed_at, computed_until, published_at`;
return s;
}
/**
- * Publishing starts the season for everyone: its clock restarts now with the same length, and the shadow run's points
- * are dropped (they were for tuning). Hiding again keeps the clock.
+ * The FIRST publish starts the season for everyone: its clock restarts now with the same length and the shadow run's
+ * points are dropped (they were for tuning). Any later publish (after hiding the boards, or of an ended season) only
+ * shows the boards again: the clock and the points stay as they are.
*/
-export async function publishSeason(id: number): Promise {
+export async function publishSeason(id: number): Promise<"started" | "shown"> {
const db = maybeDb()!;
- await db.begin(async (tx) => {
+ return db.begin(async (tx) => {
const t = tx as unknown as Db;
- await t`UPDATE bb_seasons SET public = true, ends_at = now() + (ends_at - starts_at), starts_at = now(), computed_at = NULL WHERE id = ${id} AND NOT public`;
- await t`DELETE FROM bb_points WHERE season_id = ${id}`;
- });
+ const first = await t`
+ UPDATE bb_seasons SET public = true, published_at = now(), ends_at = now() + (ends_at - starts_at), starts_at = now(), computed_at = NULL, computed_until = NULL
+ WHERE id = ${id} AND published_at IS NULL AND ends_at > now() RETURNING id`;
+ if (first.length) {
+ await t`DELETE FROM bb_points WHERE season_id = ${id}`;
+ return "started" as const;
+ }
+ await t`UPDATE bb_seasons SET public = true WHERE id = ${id}`;
+ return "shown" as const;
+ }) as Promise<"started" | "shown">;
}
export async function hideSeason(id: number): Promise {
@@ -302,9 +326,14 @@ export async function endSeasonNow(id: number): Promise {
await db`UPDATE bb_seasons SET ends_at = LEAST(ends_at, now()) WHERE id = ${id}`;
}
-/** The final standings of an ended season, before another one starts (so starting never skips them). */
-export async function finalizeIfEnded(season: Season): Promise {
- if (seasonEnded(season) && !isFinal(season)) await computeLocked(season);
+/** The final standings of an ended season, before another one starts (so starting never skips them). True = final. */
+export async function finalizeIfEnded(season: Season): Promise {
+ if (!seasonEnded(season)) return false;
+ if (isFinal(season)) return true;
+ await computeLocked(season);
+ const db = maybeDb()!;
+ const [s] = await db`SELECT id, slug, name, starts_at, ends_at, public, computed_at, computed_until, published_at FROM bb_seasons WHERE id = ${season.id}`;
+ return Boolean(s && isFinal(s));
}
/** The shadow view for admins: top wallets on each board, eligible or not, with why lines (the trial-week review). */
From 543e2c06cd00e92d22119ce949a372378daf5b81 Mon Sep 17 00:00:00 2001
From: Kevin Codex
Date: Wed, 7 Oct 2026 16:43:29 +0800
Subject: [PATCH 04/13] fix(points): review round 3 (holding = lowest balance
since the buy, from transfer history)
Every "still holding" check read the balance at compute time, which a
farmer could top up from another wallet minutes before the final count,
and the same $5 could be passed through any number of puppet wallets.
Holding is now the LOWEST balance since the buy in question, built from
bb_token_transfers (every balance change of every buyer and launcher of
the tokens in scope, netted per log position):
- real holder: held >= $5 (>= $1 eligible) every moment since the first buy
- early +50, holds and fee credit: half of THAT buy held throughout (per
buy, so taking profit later costs only the buys it touches)
- dump: dropped below half of what was bought at any point after the
creator's first sell of the season
Also: the best-3-per-day pre-selection ranks by value, publishing a
shadow season that ended unpublished is refused, and /rules says points
unlock "while the season runs".
---
app/src/app/api/points/admin/route.ts | 2 +
app/src/app/rules/page.tsx | 2 +-
app/src/lib/points/score.test.ts | 132 ++++++++++++++++----------
app/src/lib/points/score.ts | 91 ++++++++++++------
app/src/lib/points/server.ts | 21 ++--
5 files changed, 160 insertions(+), 88 deletions(-)
diff --git a/app/src/app/api/points/admin/route.ts b/app/src/app/api/points/admin/route.ts
index f6256182..a4152aa4 100644
--- a/app/src/app/api/points/admin/route.ts
+++ b/app/src/app/api/points/admin/route.ts
@@ -33,6 +33,8 @@ export async function POST(req: Request) {
}
case "publish": {
if (!season) return NextResponse.json({ error: "no season" }, { status: 404 });
+ // a shadow run that ended without ever going public has no standings anyone was told about
+ if (!season.published_at && seasonEnded(season)) return NextResponse.json({ error: `${season.name} ended without being published; start a new season` }, { status: 409 });
const r = await publishSeason(season.id);
if (r === "started") void recomputeNow().catch(() => {});
return NextResponse.json({ ok: true, result: r });
diff --git a/app/src/app/rules/page.tsx b/app/src/app/rules/page.tsx
index 9f82752f..e86ae275 100644
--- a/app/src/app/rules/page.tsx
+++ b/app/src/app/rules/page.tsx
@@ -120,7 +120,7 @@ export default function RulesPage() {
What counts as a real buyer?
A wallet whose first buy of a token was ${RULES.minTradeUsd} or more, made after the launch block, the next {RULES.sniperBlocks} blocks and the first {RULES.sniperMs / 1000} seconds. Never the launcher, a fee recipient, a wallet that got the token by transfer from either, or a wallet kept off points. A real holder is a real buyer still holding ${RULES.holderMinUsd} or more (${RULES.verifiedHolderMinUsd} for a qualifying profile, below). Only tokens priced in a quote we can price count.
How do creators earn?
On any token you launched, only what happens during the season counts. Each real holder who first bought this season and still holds a day later earns you {RULES.verifiedHolder} points if their profile qualifies (below), or {RULES.holder} if not; a week later, half again. Each holder counts once for you, on whichever of your tokens they are worth most.
Fees on buys by traders with a qualifying profile earn {RULES.creatorFeePointsPerUsd} points per $1 while they still hold half of what they bought this season (a buy and sell back earns nothing), up to {RULES.creatorFeeCapPerTraderDay} from one trader a day. Of the tokens you launch on one day, only your best {RULES.bestTokensPerDay} count. A token scores nothing if you sold during the season and now keep less than half of what you bought of it.
How do traders earn?
Be one of the first {RULES.earlyFirstBuyers} real buyers of a token that now has {RULES.earlyHoldersTarget} real holders, with your first buy this season: {RULES.early} points, and {RULES.earlyStillHolding} more while you still hold half of that first buy.
Every token you first buy this season and still half-hold a day later: {RULES.hold} points, up to {RULES.holdTokensPerDay} tokens a day. Fees you pay buying other people's tokens with {RULES.scoutFeeMinHolders}+ real holders ({RULES.scoutFeeMinEligibleHolders}+ of them qualifying), while you still hold half of what you bought: {RULES.scoutFeePointsPerUsd} points per $1, up to {RULES.scoutFeeCapPerDay} a day. Trades under ${RULES.minTradeUsd} earn nothing.
- Who appears on the leaderboard?
Profiles verified with one post on X from an account at least {RULES.eligibleMinAgeDays} days old with {RULES.eligibleMinFollowers} or more followers. Everyone else can see the points waiting for them on their dashboard; they unlock, for the whole season so far, within the hour after the profile qualifies. The post itself earns nothing.
+ Who appears on the leaderboard?
Profiles verified with one post on X from an account at least {RULES.eligibleMinAgeDays} days old with {RULES.eligibleMinFollowers} or more followers. Everyone else can see the points waiting for them on their dashboard; while the season runs, they unlock for the whole season so far within the hour after the profile qualifies. The post itself earns nothing.
diff --git a/app/src/lib/points/score.test.ts b/app/src/lib/points/score.test.ts
index 356c97c5..b933ff74 100644
--- a/app/src/lib/points/score.test.ts
+++ b/app/src/lib/points/score.test.ts
@@ -14,21 +14,25 @@ const w = (i: number) => `0x${i.toString(16).padStart(4, "0")}`;
// launched before the season: ETH at $2,000, 1% fee, token at $0.001 (so 0.0025 ETH = $5 buys 5,000 tokens)
const L: ScoreLaunch = { key: "base:0xt1", launcher: CREATOR, recipients: [RECIP], launchBlock: 100, launchTime: START - 30 * DAY, lpFee: 10_000, quoteDecimals: 18, quoteUsd: 2000, tokenUsd: 0.001 };
const tokensFor = (ethIn: number) => BigInt(Math.round((ethIn * 2000) / 0.001)) * E18;
+type Move = ScoreInput["moves"][number];
function input(over: Partial = {}): ScoreInput {
- return { seasonStart: START, seasonEnd: START + 28 * DAY, now: NOW, launches: [L], swaps: [], firstBuys: [], holders: [], launcherBought: new Map(), linked: new Map(), system: new Set(["0xpool"]), eligible: new Set(), flagged: new Set(), ...over };
+ return { seasonStart: START, seasonEnd: START + 28 * DAY, now: NOW, launches: [L], swaps: [], firstBuys: [], moves: [], launcherBought: new Map(), linked: new Map(), system: new Set(["0xpool"]), eligible: new Set(), flagged: new Set(), ...over };
}
-let li = 0;
-/** a wallet that bought `ethIn` at `t` and holds `keep` of it now */
-function buyer(wallet: string, t: number, ethIn: number, keep = 1, launch: ScoreLaunch = L, block = 1000 + li) {
- li++;
+let n = 0;
+/** a wallet that bought `ethIn` at `t` (the swap, then its token transfer in) and later sold all but `keep` of it */
+function buyer(wallet: string, t: number, ethIn: number, keep = 1, launch: ScoreLaunch = L, block = 1000 + n) {
+ n++;
const tokenRaw = tokensFor(ethIn);
+ const li = n * 10;
const swap: ScoreSwap = { key: launch.key, trader: wallet, isBuy: true, quoteRaw: eth(ethIn), tokenRaw, block, logIndex: li, time: t };
const first: FirstBuy = { key: launch.key, wallet, block, logIndex: li, time: t, quoteRaw: eth(ethIn), tokenRaw };
- return { swap, first, holder: { key: launch.key, wallet, balanceRaw: (tokenRaw * BigInt(Math.round(keep * 1000))) / 1000n } };
+ const moves: Move[] = [{ key: launch.key, wallet, block, logIndex: li + 1, delta: tokenRaw }];
+ if (keep < 1) moves.push({ key: launch.key, wallet, block: block + 50_000, logIndex: 0, delta: -((tokenRaw * BigInt(Math.round((1 - keep) * 1000))) / 1000n) });
+ return { swap, first, moves };
}
function build(buyers: ReturnType[], over: Partial = {}) {
- return input({ swaps: buyers.map((b) => b.swap), firstBuys: buyers.map((b) => b.first), holders: buyers.map((b) => b.holder), ...over });
+ return input({ swaps: buyers.map((b) => b.swap), firstBuys: buyers.map((b) => b.first), moves: buyers.flatMap((b) => b.moves), ...over });
}
// ── the rules ────────────────────────────────────────────────────────────────
@@ -39,7 +43,7 @@ test("a real holder who bought this season and held a day earns the creator 30 i
const c = r.wallets.get(CREATOR)!;
assert.equal(c.creatorWhy.verifiedHolders, 1);
assert.equal(c.creatorWhy.holders, 1);
- // fees come only from eligible traders: w(1)'s $5 at 1% = $0.05 → 0.5 points
+ // fees come only from eligible traders' held buys: w(1)'s $5 at 1% = $0.05 → 0.5 points
assert.equal(c.creator, Math.round(30 * 1.5 + 5 + 0.5));
});
@@ -82,20 +86,23 @@ test("only the best 3 tokens a wallet launched on one day count, and each holder
assert.equal(r2.wallets.get(CREATOR)!.creatorWhy.verifiedHolders, 1);
});
-test("dump: sold this season and now keeps under half of what was bought → the token scores 0; selling fee tokens is fine", () => {
+test("dump: sold this season and dropped below half of what was bought → 0; selling only the fee tokens is fine", () => {
const b = buyer(w(1), START + DAY, 0.01);
- const sell: ScoreSwap = { key: L.key, trader: CREATOR, isBuy: false, quoteRaw: eth(0.1), tokenRaw: 500_000n * E18, block: 3000, logIndex: 1, time: START + 2 * DAY };
+ const sell: ScoreSwap = { key: L.key, trader: CREATOR, isBuy: false, quoteRaw: eth(0.1), tokenRaw: 600_000n * E18, block: 3000, logIndex: 10, time: START + 2 * DAY };
const bought = new Map([[L.key, 1_000_000n * E18]]);
- const dumped = scoreSeason(build([b], { swaps: [b.swap, sell], launcherBought: bought, holders: [b.holder, { key: L.key, wallet: CREATOR, balanceRaw: 400_000n * E18 }] }));
+ const boughtIn: Move = { key: L.key, wallet: CREATOR, block: 50, logIndex: 1, delta: 1_000_000n * E18 };
+ const sold: Move = { key: L.key, wallet: CREATOR, block: 3000, logIndex: 11, delta: -600_000n * E18 };
+ const dumped = scoreSeason(build([b], { swaps: [b.swap, sell], launcherBought: bought, moves: [...b.moves, boughtIn, sold] }));
assert.equal(dumped.wallets.get(CREATOR)?.creator ?? 0, 0);
- const feeSale = scoreSeason(build([b], { swaps: [b.swap, sell], launcherBought: bought, holders: [b.holder, { key: L.key, wallet: CREATOR, balanceRaw: 1_000_000n * E18 }] }));
- assert.ok((feeSale.wallets.get(CREATOR)?.creator ?? 0) > 0, "sold 500k fee tokens but still keeps everything bought");
+ const feeIn: Move = { key: L.key, wallet: CREATOR, block: 2000, logIndex: 1, delta: 600_000n * E18 }; // fee tokens paid by the locker
+ const feeSale = scoreSeason(build([b], { swaps: [b.swap, sell], launcherBought: bought, moves: [...b.moves, boughtIn, feeIn, sold] }));
+ assert.ok((feeSale.wallets.get(CREATOR)?.creator ?? 0) > 0, "sold the fee tokens, kept everything bought");
});
-test("early: first 25 real buyers of a token with 50+ real holders; +50 only while half of that buy is held", () => {
+test("early: first 25 real buyers of a token with 50+ real holders; +50 only if half of that buy was held throughout", () => {
const buyers = Array.from({ length: 52 }, (_, i) => buyer(w(i + 1), START + DAY + i * 1000, 0.003, i === 0 ? 0.3 : 1, L, 1000 + i));
const r = scoreSeason(build(buyers));
- assert.equal(r.realHolders.get(L.key), 52 - 1, "w(1) kept $1.80: not a real holder (unverified needs $5)");
+ assert.equal(r.realHolders.get(L.key), 52 - 1, "w(1) dropped to $1.80: not a real holder (unverified needs $5)");
assert.equal(r.wallets.get(w(1))!.scoutWhy.early, 1);
assert.equal(r.wallets.get(w(2))!.scout - r.wallets.get(w(1))!.scout, RULES.earlyStillHolding + RULES.hold);
assert.equal(r.wallets.get(w(26))?.scoutWhy.early ?? 0, 0);
@@ -108,60 +115,81 @@ test("early slots go to real buyers only: sub-$5 first buys cannot crowd them ou
assert.equal(r.wallets.get(w(1))!.scoutWhy.early, 1);
});
-test("holds: first buy ≥ $5 this season, half still held a day later; 20 tokens a day", () => {
+test("holds: first buy ≥ $5 this season, half held throughout for a day; 20 tokens a day", () => {
const launches = Array.from({ length: 25 }, (_, i) => ({ ...L, key: `base:0xh${i}` }));
- const buys = launches.map((l) => buyer(w(1), START + DAY, 0.003, 1, l));
- const r = scoreSeason(build(buys, { launches }));
+ const r = scoreSeason(build(launches.map((l) => buyer(w(1), START + DAY, 0.003, 1, l)), { launches }));
assert.equal(r.wallets.get(w(1))!.scoutWhy.holds, RULES.holdTokensPerDay);
const soldDown = scoreSeason(build([buyer(w(2), START + DAY, 0.003, 0.4)]));
assert.equal(soldDown.wallets.get(w(2))?.scoutWhy.holds ?? 0, 0, "sold down to 40%: no hold");
});
-test("scout fees: net buyers on tokens with 20+ real holders (5+ eligible), at most 200 a day; round trips earn nothing", () => {
+test("scout fees: held buys on tokens with 20+ real holders (5+ eligible), at most 200 a day; round trips earn nothing", () => {
const holders = Array.from({ length: 20 }, (_, i) => buyer(w(100 + i), START + DAY, 0.003));
const eligible = new Set(holders.slice(0, 5).map((h) => h.first.wallet));
- // w(1) buys 1 ETH 30 times and keeps all of it: $20 fees each → capped at 200 points that day
- const buys: ScoreSwap[] = Array.from({ length: 30 }, (_, i) => ({ key: L.key, trader: w(1), isBuy: true, quoteRaw: eth(1), tokenRaw: tokensFor(1), block: 6000 + i, logIndex: i, time: START + 2 * DAY + i }));
- const kept = { key: L.key, wallet: w(1), balanceRaw: tokensFor(1) * 30n };
- const base = { swaps: [...holders.map((h) => h.swap), ...buys], firstBuys: holders.map((h) => h.first), holders: [...holders.map((h) => h.holder), kept], eligible };
- assert.equal(scoreSeason(input(base)).wallets.get(w(1))!.scoutWhy.feesUsd > 0, true);
- assert.equal(scoreSeason(input(base)).wallets.get(w(1))!.scout <= RULES.scoutFeeCapPerDay + RULES.hold, true);
- const roundTrip = scoreSeason(input({ ...base, holders: holders.map((h) => h.holder) })); // sold it all again
- assert.equal(roundTrip.wallets.get(w(1))?.scoutWhy.feesUsd ?? 0, 0, "a round trip earns no fee points");
- const fewEligible = scoreSeason(input({ ...base, eligible: new Set() }));
- assert.equal(fewEligible.wallets.get(w(1))?.scoutWhy.feesUsd ?? 0, 0, "a token without 5 eligible holders earns no scout fees");
- const thin = scoreSeason(input({ swaps: buys, holders: [kept] }));
- assert.equal(thin.wallets.get(w(1))?.scoutWhy.feesUsd ?? 0, 0, "no real holders: no scout fee points");
-});
-
-// ── the exploits the review found, as regressions ────────────────────────────
-test("exploit: an unpriced quote (a farmer's own ERC-20) earns nothing at all", () => {
+ const buys: ScoreSwap[] = Array.from({ length: 30 }, (_, i) => ({ key: L.key, trader: w(1), isBuy: true, quoteRaw: eth(1), tokenRaw: tokensFor(1), block: 6000 + i, logIndex: 10, time: START + 2 * DAY + i }));
+ const ins: Move[] = buys.map((b) => ({ key: L.key, wallet: w(1), block: b.block, logIndex: 11, delta: b.tokenRaw }));
+ const base = { swaps: [...holders.map((h) => h.swap), ...buys], firstBuys: holders.map((h) => h.first), moves: [...holders.flatMap((h) => h.moves), ...ins], eligible };
+ const kept = scoreSeason(input(base)).wallets.get(w(1))!;
+ assert.ok(kept.scoutWhy.feesUsd > 0);
+ assert.ok(kept.scout <= RULES.scoutFeeCapPerDay + RULES.hold);
+ const roundTrip = scoreSeason(input({ ...base, moves: [...base.moves, { key: L.key, wallet: w(1), block: 9000, logIndex: 0, delta: -tokensFor(1) * 30n }] }));
+ assert.equal(roundTrip.wallets.get(w(1))?.scoutWhy.feesUsd ?? 0, 0, "sold it all again: no fee points for any of those buys");
+ assert.equal(scoreSeason(input({ ...base, eligible: new Set() })).wallets.get(w(1))?.scoutWhy.feesUsd ?? 0, 0, "fewer than 5 eligible holders");
+ assert.equal(scoreSeason(input({ swaps: buys, moves: ins })).wallets.get(w(1))?.scoutWhy.feesUsd ?? 0, 0, "no real holders");
+});
+
+// ── the farms the reviews found, as regressions ──────────────────────────────
+test("farm: an unpriced quote (a farmer's own ERC-20) earns nothing at all", () => {
const fake: ScoreLaunch = { ...L, key: "base:0xfake", quoteUsd: null, tokenUsd: null, launchTime: START + DAY };
const socks = Array.from({ length: 50 }, (_, i) => buyer(w(i + 1), START + DAY + 60_000 + i, 1, 1, fake, 500 + i));
- const r = scoreSeason(build(socks, { launches: [fake], eligible: new Set([w(1)]) }));
- assert.equal(r.wallets.size, 0);
+ assert.equal(scoreSeason(build(socks, { launches: [fake], eligible: new Set([w(1)]) })).wallets.size, 0);
});
-test("exploit: dust sent to 50 wallets makes none of them holders and unlocks nothing", () => {
- const scout = buyer(w(1), START + DAY, 0.0025, 0); // bought $5, sold to dust
- const dust = Array.from({ length: 50 }, (_, i) => ({ key: L.key, wallet: w(1000 + i), balanceRaw: 1n }));
- const r = scoreSeason(input({ swaps: [scout.swap], firstBuys: [scout.first], holders: [{ ...scout.holder, balanceRaw: 1n }, ...dust], eligible: new Set([w(1)]) }));
+test("farm: dust sent to 50 wallets makes none of them holders and unlocks nothing", () => {
+ const scout = buyer(w(1), START + DAY, 0.0025, 0.0001);
+ const dust: Move[] = Array.from({ length: 50 }, (_, i) => ({ key: L.key, wallet: w(1000 + i), block: 70_000, logIndex: i, delta: 1n }));
+ const r = scoreSeason(build([scout], { moves: [...scout.moves, ...dust], eligible: new Set([w(1)]) }));
assert.equal(r.realHolders.get(L.key) ?? 0, 0);
- assert.equal(r.wallets.get(w(1))?.scoutWhy.early ?? 0, 0);
- assert.equal(r.wallets.get(w(1))?.scoutWhy.holds ?? 0, 0);
+ assert.equal(r.wallets.get(w(1))?.scout ?? 0, 0);
+});
+
+test("farm: wash trading earns nothing, verified or not; a verified buyer holding throughout gives the creator at most 50 a day", () => {
+ const swaps: ScoreSwap[] = [];
+ const moves: Move[] = [];
+ for (let i = 0; i < 100; i++) {
+ const buy = i % 2 === 0;
+ swaps.push({ key: L.key, trader: "0xpuppet", isBuy: buy, quoteRaw: eth(10), tokenRaw: tokensFor(10), block: 1000 + i, logIndex: 10, time: START + 2 * DAY + i * 1000 });
+ moves.push({ key: L.key, wallet: "0xpuppet", block: 1000 + i, logIndex: 11, delta: buy ? tokensFor(10) : -tokensFor(10) });
+ }
+ assert.equal(scoreSeason(input({ swaps, moves })).wallets.get(CREATOR)?.creator ?? 0, 0);
+ assert.equal(scoreSeason(input({ swaps, moves, eligible: new Set(["0xpuppet"]) })).wallets.get(CREATOR)?.creator ?? 0, 0, "round trips by a verified wallet");
+ const net = scoreSeason(input({ swaps: swaps.filter((x) => x.isBuy), moves: moves.filter((m) => m.delta > 0n), eligible: new Set(["0xpuppet"]) }));
+ assert.equal(net.wallets.get(CREATOR)!.creator, RULES.creatorFeeCapPerTraderDay, "$500 of buys held: capital at stake, capped");
+});
+
+test("farm: topping a wallet back up by transfer before the final count restores nothing", () => {
+ // bought $5, sold out, then another wallet sends tokens back just before the count
+ const b = buyer(w(1), START + DAY, 0.0025, 0);
+ const topUp: Move = { key: L.key, wallet: w(1), block: 900_000, logIndex: 0, delta: tokensFor(0.0025) * 10n };
+ const r = scoreSeason(build([b], { moves: [...b.moves, topUp], eligible: new Set([w(1)]) }));
+ assert.equal(r.realHolders.get(L.key) ?? 0, 0);
+ assert.equal(r.wallets.get(CREATOR)?.creator ?? 0, 0);
+ assert.equal(r.wallets.get(w(1))?.scout ?? 0, 0);
});
-test("exploit: wash trading earns nothing, verified or not; a verified net buyer gives the creator at most 50 a day", () => {
- const wash: ScoreSwap[] = Array.from({ length: 100 }, (_, i) => ({ key: L.key, trader: "0xpuppet", isBuy: i % 2 === 0, quoteRaw: eth(10), tokenRaw: tokensFor(10), block: 1000 + i, logIndex: i, time: START + 2 * DAY + i * 1000 }));
- assert.equal(scoreSeason(input({ swaps: wash })).wallets.get(CREATOR)?.creator ?? 0, 0);
- assert.equal(scoreSeason(input({ swaps: wash, eligible: new Set(["0xpuppet"]) })).wallets.get(CREATOR)?.creator ?? 0, 0, "round trips by a verified wallet: the fees come back, no points");
- const buys = wash.filter((x) => x.isBuy);
- const holding = { key: L.key, wallet: "0xpuppet", balanceRaw: tokensFor(10) * 50n };
- const net = scoreSeason(input({ swaps: buys, holders: [holding], eligible: new Set(["0xpuppet"]) }));
- assert.equal(net.wallets.get(CREATOR)!.creator, RULES.creatorFeeCapPerTraderDay, "$500 of buys still held: capital at stake, capped");
+test("farm: $5 passed from puppet to puppet counts for none of them", () => {
+ const puppets = Array.from({ length: 30 }, (_, i) => {
+ const b = buyer(w(200 + i), START + DAY + i * 1000, 0.0025);
+ // each puppet hands its tokens on to a collector right after buying
+ b.moves.push({ key: L.key, wallet: w(200 + i), block: b.swap.block + 1, logIndex: 0, delta: -b.swap.tokenRaw });
+ return b;
+ });
+ const r = scoreSeason(build(puppets));
+ assert.equal(r.realHolders.get(L.key) ?? 0, 0);
+ assert.equal(r.wallets.get(CREATOR)?.creator ?? 0, 0);
});
-test("exploit: a wallet kept off points (deleted profile or not) scores nothing and counts for no one", () => {
+test("farm: a wallet kept off points (deleted profile or not) scores nothing and counts for no one", () => {
const b = buyer(w(1), START + DAY, 0.01);
const r = scoreSeason(build([b], { flagged: new Set([w(1)]), eligible: new Set([w(1)]) }));
assert.equal(r.wallets.has(w(1)), false);
diff --git a/app/src/lib/points/score.ts b/app/src/lib/points/score.ts
index 266d9a2d..6cc0a2eb 100644
--- a/app/src/lib/points/score.ts
+++ b/app/src/lib/points/score.ts
@@ -7,23 +7,26 @@
* real buyer = a wallet whose FIRST buy of a token was ≥ $5, outside the sniper window (the launch block + 3, and
* the first 12 seconds), and that is not the launcher, a fee recipient, a protocol address, a wallet
* that got the token by transfer from the launcher or a recipient, or a wallet kept off points
- * real holder = a real buyer still holding ≥ $5 of it (≥ $1 if their profile is points-eligible)
+ * real holder = a real buyer who has held ≥ $5 of it (≥ $1 if their profile is points-eligible) ever since that buy
+ *
+ * "Held" always means the LOWEST balance since the buy in question, read from the transfer history: tokens sent in
+ * later by another wallet cannot top a position back up, and $5 passed from wallet to wallet counts for none of them.
*
* Only tokens whose quote has a price count at all. Then:
*
* Creator (any token the wallet launched; only activity inside the season counts)
* - each real holder whose first buy was in the season, a day or more ago: 30 if their profile is points-eligible,
* else 5; a week or more ago: half again. A holder counts once per creator (their best token).
- * - 10 per $1 of fees on buys ≥ $5 by points-eligible outside traders who still hold half of what they bought this
- season (net buyers: a round trip earns nothing); at most 50 per trader per creator per day
- * - a token scores 0 if its creator sold during the season and now keeps less than half of what they bought
+ * - 10 per $1 of fees on buys ≥ $5 by points-eligible outside traders, for each buy half of which has been held ever
+ * since (a round trip earns nothing); at most 50 per trader per creator per day
+ * - a token scores 0 if its creator sold during the season and has since dropped below half of what they bought
* - of the tokens a wallet launched on one UTC day, only the best 3 count
* Scout (traders)
* - one of the first 25 real buyers of a token that now has 50+ real holders, buying in the season: 100, and 50
* more while still holding half of that first buy
* - 5 per token first bought in the season (≥ $5) and still half-held a day later; at most 20 tokens per UTC day
- * - 10 per $1 of fees paid on buys ≥ $5 of other people's tokens with 20+ real holders (5+ of them eligible), while
- still holding half of what you bought of it this season; at most 200 a day
+ * - 10 per $1 of fees paid on buys ≥ $5 of other people's tokens with 20+ real holders (5+ of them eligible), for
+ * each buy half of which has been held ever since; at most 200 a day
*
* Only points-eligible wallets rank; everyone else sees what is waiting for them.
*/
@@ -46,7 +49,6 @@ export const RULES = {
earlyHoldersTarget: 50,
hold: 5,
holdTokensPerDay: 20,
- heldShare: 0.5,
scoutFeePointsPerUsd: 10,
scoutFeeCapPerDay: 200,
scoutFeeMinHolders: 20,
@@ -81,8 +83,8 @@ export type ScoreInput = {
/** swaps inside the season, any order */
swaps: readonly ScoreSwap[];
firstBuys: readonly FirstBuy[];
- /** current balances (raw, 18 decimals) of the tokens in scope, for wallets that ever bought them */
- holders: readonly { key: string; wallet: string; balanceRaw: bigint }[];
+ /** every balance change (raw, 18 decimals) of buyers and launchers of the tokens in scope, from the transfer history */
+ moves: readonly { key: string; wallet: string; block: number; logIndex: number; delta: bigint }[];
/** the launcher's all-time bought amount (raw) per token */
launcherBought: ReadonlyMap;
/** per token: wallets that received it by transfer from the launcher or a fee recipient */
@@ -120,10 +122,39 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
const quoteUsd = (l: ScoreLaunch, raw: bigint) => units(raw, l.quoteDecimals) * (l.quoteUsd as number);
const tokenUsd = (l: ScoreLaunch, raw: bigint) => units(raw, 18) * (l.tokenUsd as number);
const inSeason = (t: number) => t >= input.seasonStart && t < end;
- const balanceOf = new Map();
- for (const h of input.holders) if (h.balanceRaw > 0n) balanceOf.set(`${h.key}|${h.wallet}`, h.balanceRaw);
- const bal = (key: string, w: string) => balanceOf.get(`${key}|${w}`) ?? 0n;
- const halfHeld = (key: string, w: string, boughtRaw: bigint) => bal(key, w) * 2n >= boughtRaw && bal(key, w) > 0n;
+ // balance timelines per (token, wallet): the running balance after each transfer, in chain order
+ type Point = { block: number; logIndex: number; balance: bigint };
+ const timelines = new Map();
+ {
+ const net = new Map>();
+ for (const m of input.moves) {
+ const k = `${m.key}|${m.wallet}`;
+ const at = `${m.block}:${m.logIndex}`;
+ const byAt = net.get(k) ?? new Map();
+ const cur = byAt.get(at);
+ byAt.set(at, { block: m.block, logIndex: m.logIndex, delta: (cur?.delta ?? 0n) + m.delta }); // a self-transfer nets to 0
+ net.set(k, byAt);
+ }
+ for (const [k, byAt] of net) {
+ let balance = 0n;
+ const pts: Point[] = [...byAt.values()].sort((a, b) => a.block - b.block || a.logIndex - b.logIndex).map((x) => ({ block: x.block, logIndex: x.logIndex, balance: (balance += x.delta) }));
+ timelines.set(k, pts);
+ }
+ }
+ /** The lowest balance after the given position (a swap: its token transfer comes right after it). 0 when unknown. */
+ const minAfter = (key: string, w: string, block: number, logIndex: number): bigint => {
+ let min: bigint | null = null;
+ for (const p of timelines.get(`${key}|${w}`) ?? []) {
+ if (p.block < block || (p.block === block && p.logIndex <= logIndex)) continue;
+ if (min === null || p.balance < min) min = p.balance;
+ }
+ return min !== null && min > 0n ? min : 0n;
+ };
+ /** Held at least half of a buy, every moment since it. */
+ const halfHeldSince = (key: string, w: string, block: number, logIndex: number, boughtRaw: bigint) => {
+ const m = minAfter(key, w, block, logIndex);
+ return m > 0n && m * 2n >= boughtRaw;
+ };
// real buyers per token, in buy order
const realBuyers = new Map();
@@ -140,27 +171,28 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
const realHolders = new Map();
for (const [key, list] of realBuyers) {
const l = launches.get(key)!;
- const held = list.filter((f) => tokenUsd(l, bal(key, f.wallet)) >= (input.eligible.has(f.wallet) ? RULES.verifiedHolderMinUsd : RULES.holderMinUsd));
+ const held = list.filter((f) => tokenUsd(l, minAfter(key, f.wallet, f.block, f.logIndex)) >= (input.eligible.has(f.wallet) ? RULES.verifiedHolderMinUsd : RULES.holderMinUsd));
realHolders.set(key, held);
}
const holderCount = (key: string) => realHolders.get(key)?.length ?? 0;
// the season's outside trades (≥ $5, outside the sniper window), and what each creator sold
const outside: ScoreSwap[] = [];
- const creatorSold = new Set();
+ const creatorFirstSell = new Map();
for (const s of input.swaps) {
const l = launches.get(s.key);
if (!l || !inSeason(s.time)) continue;
- if (s.trader === l.launcher && !s.isBuy) creatorSold.add(s.key);
+ if (s.trader === l.launcher && !s.isBuy) {
+ const cur = creatorFirstSell.get(s.key);
+ if (!cur || s.block < cur.block || (s.block === cur.block && s.logIndex < cur.logIndex)) creatorFirstSell.set(s.key, s);
+ }
if (insider(l, s.trader) || sniper(l, s.block, s.time) || quoteUsd(l, s.quoteRaw) < RULES.minTradeUsd) continue;
outside.push(s);
}
- // net buyers: a trader's buys of a token earn fee points only while they hold half of what they bought this season,
- // so a round trip (the fees coming back to a farmer's own token) earns nothing and washing ties up real capital
- const seasonBought = new Map();
- for (const s of outside) if (s.isBuy) seasonBought.set(`${s.key}|${s.trader}`, (seasonBought.get(`${s.key}|${s.trader}`) ?? 0n) + s.tokenRaw);
- const netBuyer = (key: string, w: string) => halfHeld(key, w, seasonBought.get(`${key}|${w}`) ?? 0n);
+ // net buying: a buy earns fee points only if at least half of it has been held every moment since, so a round trip
+ // (the fees coming back to a farmer's own token) earns nothing and washing ties up real capital for the season
+ const heldBuy = (s: ScoreSwap) => halfHeldSince(s.key, s.trader, s.block, s.logIndex, s.tokenRaw);
const eligibleHolders = new Map();
for (const [key, list] of realHolders) eligibleHolders.set(key, list.filter((f) => input.eligible.has(f.wallet)).length);
@@ -186,15 +218,17 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
}
for (const s of outside) {
// fees count only on buys by points-eligible traders who are net buyers: wash trades need real accounts and capital
- if (!s.isBuy || !input.eligible.has(s.trader) || !netBuyer(s.key, s.trader)) continue;
+ if (!s.isBuy || !input.eligible.has(s.trader) || !heldBuy(s)) continue;
const l = launches.get(s.key)!;
const fee = (quoteUsd(l, s.quoteRaw) * l.lpFee) / 1_000_000;
if (fee > 0) ensure(l).fees.push({ trader: s.trader, day: utcDay(s.time), usd: fee });
}
for (const t of perToken.values()) {
const bought = input.launcherBought.get(t.l.key) ?? 0n;
- // a dump = sold during the season and now keeps less than half of what was bought (selling fee tokens is not one)
- if (bought > 0n && creatorSold.has(t.l.key) && bal(t.l.key, t.l.launcher) * 2n < bought) t.dumped = true;
+ const sell = creatorFirstSell.get(t.l.key);
+ // a dump = sold during the season and dropped below half of what was bought at any point since (selling the fee
+ // tokens the pool paid out, while keeping what was bought, is not one)
+ if (bought > 0n && sell && minAfter(t.l.key, t.l.launcher, sell.block, sell.logIndex) * 2n < bought) t.dumped = true;
}
// per creator: each holder once (their best token), fees capped per trader per day, best 3 tokens per launch day
const byCreator = new Map();
@@ -231,7 +265,8 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
const d = utcDay(t.l.launchTime);
daysFirst.set(d, [...(daysFirst.get(d) ?? []), t]);
}
- for (const list of daysFirst.values()) for (const t of [...list].sort((a, b) => (firstPass.get(b) ?? 0) + b.fees.length - ((firstPass.get(a) ?? 0) + a.fees.length)).slice(0, RULES.bestTokensPerDay)) kept.add(t);
+ const rough = (t: TokenCreator) => (firstPass.get(t) ?? 0) + t.fees.reduce((a, f) => a + f.usd * RULES.creatorFeePointsPerUsd, 0);
+ for (const list of daysFirst.values()) for (const t of [...list].sort((a, b) => rough(b) - rough(a)).slice(0, RULES.bestTokensPerDay)) kept.add(t);
const holderBest = assign((t) => kept.has(t));
for (const [, h] of holderBest) value.set(h.t, (value.get(h.t) ?? 0) + h.pts);
const feeUsd = new Map();
@@ -279,7 +314,7 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
for (const f of list.slice(0, RULES.earlyFirstBuyers)) {
if (!inSeason(f.time)) continue;
const s = get(f.wallet);
- s.scout += RULES.early + (halfHeld(key, f.wallet, f.tokenRaw) ? RULES.earlyStillHolding : 0);
+ s.scout += RULES.early + (halfHeldSince(key, f.wallet, f.block, f.logIndex, f.tokenRaw) ? RULES.earlyStillHolding : 0);
s.scoutWhy.early++;
}
}
@@ -292,7 +327,7 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
}
const holdsPerDay = new Map();
for (const s of [...firstSeasonBuy.values()].sort((a, b) => a.time - b.time)) {
- if (end - s.time < DAY || !halfHeld(s.key, s.trader, s.tokenRaw)) continue;
+ if (end - s.time < DAY || !heldBuy(s)) continue;
const dayKey = `${s.trader}|${utcDay(s.time)}`;
const n = holdsPerDay.get(dayKey) ?? 0;
if (n >= RULES.holdTokensPerDay) continue;
@@ -303,7 +338,7 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
}
const scoutFeeUsed = new Map();
for (const s of outside) {
- if (!s.isBuy || !netBuyer(s.key, s.trader)) continue;
+ if (!s.isBuy || !heldBuy(s)) continue;
if (holderCount(s.key) < RULES.scoutFeeMinHolders || (eligibleHolders.get(s.key) ?? 0) < RULES.scoutFeeMinEligibleHolders) continue;
const l = launches.get(s.key)!;
const fee = (quoteUsd(l, s.quoteRaw) * l.lpFee) / 1_000_000;
diff --git a/app/src/lib/points/server.ts b/app/src/lib/points/server.ts
index 5d0c5817..6720a1e4 100644
--- a/app/src/lib/points/server.ts
+++ b/app/src/lib/points/server.ts
@@ -110,12 +110,19 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis
SELECT DISTINCT ON (s.chain_id, s.token, s.trader) s.chain_id, s.token, s.trader, s.block_number, s.log_index, s.block_time AS t, s.amount0, s.amount1
FROM bb_launch_swaps s JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON s.chain_id = u.cid AND s.token = u.tok
WHERE s.is_buy AND s.trader IS NOT NULL ORDER BY s.chain_id, s.token, s.trader, s.block_number, s.log_index`;
- // balances of wallets that ever bought the token (airdrop / dust recipients never matter to any rule)
- const holderRows = await read<{ chain_id: number; token: string; holder: string; balance: string }[]>`
- SELECT h.chain_id, h.token, h.holder, h.balance
- FROM bb_token_holders h JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON h.chain_id = u.cid AND h.token = u.tok
- WHERE h.balance > 0
- AND EXISTS (SELECT 1 FROM bb_launch_swaps s WHERE s.trader = h.holder AND s.chain_id = h.chain_id AND s.token = h.token AND s.is_buy)`;
+ // every balance change of every buyer (and launcher) of the tokens in scope: holding is judged on the lowest balance
+ // since a buy, so tokens topped up later by transfer, or $5 passed from wallet to wallet, never count
+ const pairs = new Map();
+ for (const f of firstRows) pairs.set(`${f.chain_id}|${f.token}|${f.trader}`, { cid: f.chain_id, tok: f.token, w: f.trader });
+ for (const l of launchRows) pairs.set(`${l.chain_id}|${l.token}|${l.launcher}`, { cid: l.chain_id, tok: l.token, w: l.launcher });
+ const pv = [...pairs.values()];
+ const moveRows = await read<{ chain_id: number; token: string; wallet: string; block_number: bigint; log_index: number; delta: string }[]>`
+ WITH p AS (SELECT * FROM unnest(${pv.map((x) => x.cid)}::int[], ${pv.map((x) => x.tok)}::text[], ${pv.map((x) => x.w)}::text[]) AS p(cid, tok, w))
+ SELECT t.chain_id, t.token, p.w AS wallet, t.block_number, t.log_index, t.value::text AS delta
+ FROM bb_token_transfers t JOIN p ON t.chain_id = p.cid AND t.token = p.tok AND t.to_addr = p.w
+ UNION ALL
+ SELECT t.chain_id, t.token, p.w AS wallet, t.block_number, t.log_index, (-t.value)::text AS delta
+ FROM bb_token_transfers t JOIN p ON t.chain_id = p.cid AND t.token = p.tok AND t.from_addr = p.w`;
const boughtRows = await read<{ chain_id: number; token: string; bought: string }[]>`
SELECT s.chain_id, s.token, sum(s.amount1)::text AS bought
FROM bb_launch_swaps s JOIN bb_launches l ON l.chain_id = s.chain_id AND l.token = s.token
@@ -159,7 +166,7 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis
launches,
swaps,
firstBuys,
- holders: holderRows.map((h) => ({ key: keyOf(h.chain_id, h.token), wallet: h.holder, balanceRaw: int(h.balance) })),
+ moves: moveRows.map((m) => ({ key: keyOf(m.chain_id, m.token), wallet: m.wallet, block: Number(m.block_number), logIndex: Number(m.log_index), delta: m.delta.startsWith("-") ? -int(m.delta) : int(m.delta) })),
launcherBought: new Map(boughtRows.map((b) => [keyOf(b.chain_id, b.token), int(b.bought)])),
linked,
system,
From d114688cec8cf153ebe261d9fa8acea6d748b67f Mon Sep 17 00:00:00 2001
From: Kevin Codex
Date: Wed, 7 Oct 2026 16:52:05 +0800
Subject: [PATCH 05/13] fix(points): review round 4 (holding judged per buy
with LIFO lots, matched by transaction)
A wallet holding a small base position all season could still round-trip
for fee points: the lowest-balance check compared each buy with the whole
balance, which the base kept above half. Holding is now per buy: every
incoming transfer is a lot, every outgoing one (a sell or a move to any
wallet) uses up the newest lots first, and a buy counts while at least
half of its own lot is left. A round trip uses up its own lot; tokens
sent in later are new lots that cover no earlier buy; $5 relayed between
puppets counts for none. Lots are matched to buys by transaction, so a
router that logs the transfer before the swap is handled, and the pass is
linear (the previous per-buy scan was quadratic on busy wallets).
The dump rule compares the creator's own buy lots with what they bought.
/rules says moving a position out counts as selling it.
---
app/src/app/rules/page.tsx | 6 +-
app/src/lib/points/score.test.ts | 65 ++++++++++++++------
app/src/lib/points/score.ts | 101 +++++++++++++++++--------------
app/src/lib/points/server.ts | 33 +++++-----
4 files changed, 122 insertions(+), 83 deletions(-)
diff --git a/app/src/app/rules/page.tsx b/app/src/app/rules/page.tsx
index e86ae275..d26c41ef 100644
--- a/app/src/app/rules/page.tsx
+++ b/app/src/app/rules/page.tsx
@@ -117,9 +117,9 @@ export default function RulesPage() {
Season points
Points for real buyers. Nothing for noise.
See the leaderboard
Points measure reputation on openlaunch. They have no cash value, are not a token and not a promise of one, cannot be transferred or sold, and the rules can change between seasons. Wallets that game the system lose their points. Everything is computed from the chain every hour; the rules below are the whole of it.
- What counts as a real buyer?
A wallet whose first buy of a token was ${RULES.minTradeUsd} or more, made after the launch block, the next {RULES.sniperBlocks} blocks and the first {RULES.sniperMs / 1000} seconds. Never the launcher, a fee recipient, a wallet that got the token by transfer from either, or a wallet kept off points. A real holder is a real buyer still holding ${RULES.holderMinUsd} or more (${RULES.verifiedHolderMinUsd} for a qualifying profile, below). Only tokens priced in a quote we can price count.
- How do creators earn?
On any token you launched, only what happens during the season counts. Each real holder who first bought this season and still holds a day later earns you {RULES.verifiedHolder} points if their profile qualifies (below), or {RULES.holder} if not; a week later, half again. Each holder counts once for you, on whichever of your tokens they are worth most.
Fees on buys by traders with a qualifying profile earn {RULES.creatorFeePointsPerUsd} points per $1 while they still hold half of what they bought this season (a buy and sell back earns nothing), up to {RULES.creatorFeeCapPerTraderDay} from one trader a day. Of the tokens you launch on one day, only your best {RULES.bestTokensPerDay} count. A token scores nothing if you sold during the season and now keep less than half of what you bought of it.
- How do traders earn?
Be one of the first {RULES.earlyFirstBuyers} real buyers of a token that now has {RULES.earlyHoldersTarget} real holders, with your first buy this season: {RULES.early} points, and {RULES.earlyStillHolding} more while you still hold half of that first buy.
Every token you first buy this season and still half-hold a day later: {RULES.hold} points, up to {RULES.holdTokensPerDay} tokens a day. Fees you pay buying other people's tokens with {RULES.scoutFeeMinHolders}+ real holders ({RULES.scoutFeeMinEligibleHolders}+ of them qualifying), while you still hold half of what you bought: {RULES.scoutFeePointsPerUsd} points per $1, up to {RULES.scoutFeeCapPerDay} a day. Trades under ${RULES.minTradeUsd} earn nothing.
+ What counts as a real buyer?
A wallet whose first buy of a token was ${RULES.minTradeUsd} or more, made after the launch block, the next {RULES.sniperBlocks} blocks and the first {RULES.sniperMs / 1000} seconds. Never the launcher, a fee recipient, a wallet that got the token by transfer from either, or a wallet kept off points. A real holder is a real buyer with ${RULES.holderMinUsd} or more (${RULES.verifiedHolderMinUsd} for a qualifying profile, below) of that first buy still in the wallet.
Holding is judged per buy: whenever tokens leave a wallet (sold, or moved to any other wallet), the most recently received ones go first. So selling a later buy does not touch an earlier one, tokens sent in from another wallet never stand in for a buy, and moving a position out counts the same as selling it. Only tokens priced in a quote we can price count.
+ How do creators earn?
On any token you launched, only what happens during the season counts. Each real holder who first bought this season and still holds a day later earns you {RULES.verifiedHolder} points if their profile qualifies (below), or {RULES.holder} if not; a week later, half again. Each holder counts once for you, on whichever of your tokens they are worth most.
Fees on buys by traders with a qualifying profile earn {RULES.creatorFeePointsPerUsd} points per $1 for each buy that is still at least half held (a buy sold straight back earns nothing), up to {RULES.creatorFeeCapPerTraderDay} from one trader a day. Of the tokens you launch on one day, only your best {RULES.bestTokensPerDay} count. A token scores nothing if you sold during the season and less than half of your own buys of it is left.
+ How do traders earn?
Be one of the first {RULES.earlyFirstBuyers} real buyers of a token that now has {RULES.earlyHoldersTarget} real holders, with your first buy this season: {RULES.early} points, and {RULES.earlyStillHolding} more while you still hold half of that first buy.
Every token you first buy this season and still half-hold a day later (that buy itself, not a later one): {RULES.hold} points, up to {RULES.holdTokensPerDay} tokens a day. Fees you pay buying other people's tokens with {RULES.scoutFeeMinHolders}+ real holders ({RULES.scoutFeeMinEligibleHolders}+ of them qualifying), for each buy still at least half held: {RULES.scoutFeePointsPerUsd} points per $1, up to {RULES.scoutFeeCapPerDay} a day. Trades under ${RULES.minTradeUsd} earn nothing.
Who appears on the leaderboard?
Profiles verified with one post on X from an account at least {RULES.eligibleMinAgeDays} days old with {RULES.eligibleMinFollowers} or more followers. Everyone else can see the points waiting for them on their dashboard; while the season runs, they unlock for the whole season so far within the hour after the profile qualifies. The post itself earns nothing.
diff --git a/app/src/lib/points/score.test.ts b/app/src/lib/points/score.test.ts
index b933ff74..0e91aa8b 100644
--- a/app/src/lib/points/score.test.ts
+++ b/app/src/lib/points/score.test.ts
@@ -17,7 +17,7 @@ const tokensFor = (ethIn: number) => BigInt(Math.round((ethIn * 2000) / 0.001))
type Move = ScoreInput["moves"][number];
function input(over: Partial = {}): ScoreInput {
- return { seasonStart: START, seasonEnd: START + 28 * DAY, now: NOW, launches: [L], swaps: [], firstBuys: [], moves: [], launcherBought: new Map(), linked: new Map(), system: new Set(["0xpool"]), eligible: new Set(), flagged: new Set(), ...over };
+ return { seasonStart: START, seasonEnd: START + 28 * DAY, now: NOW, launches: [L], swaps: [], firstBuys: [], moves: [], launcherBuys: new Map(), linked: new Map(), system: new Set(["0xpool"]), eligible: new Set(), flagged: new Set(), ...over };
}
let n = 0;
/** a wallet that bought `ethIn` at `t` (the swap, then its token transfer in) and later sold all but `keep` of it */
@@ -25,10 +25,11 @@ function buyer(wallet: string, t: number, ethIn: number, keep = 1, launch: Score
n++;
const tokenRaw = tokensFor(ethIn);
const li = n * 10;
- const swap: ScoreSwap = { key: launch.key, trader: wallet, isBuy: true, quoteRaw: eth(ethIn), tokenRaw, block, logIndex: li, time: t };
- const first: FirstBuy = { key: launch.key, wallet, block, logIndex: li, time: t, quoteRaw: eth(ethIn), tokenRaw };
- const moves: Move[] = [{ key: launch.key, wallet, block, logIndex: li + 1, delta: tokenRaw }];
- if (keep < 1) moves.push({ key: launch.key, wallet, block: block + 50_000, logIndex: 0, delta: -((tokenRaw * BigInt(Math.round((1 - keep) * 1000))) / 1000n) });
+ const tx = `0xbuy${n}`;
+ const swap: ScoreSwap = { key: launch.key, trader: wallet, isBuy: true, quoteRaw: eth(ethIn), tokenRaw, block, logIndex: li, time: t, tx };
+ const first: FirstBuy = { key: launch.key, wallet, block, logIndex: li, time: t, quoteRaw: eth(ethIn), tokenRaw, tx };
+ const moves: Move[] = [{ key: launch.key, wallet, block, logIndex: li + 1, tx, delta: tokenRaw }];
+ if (keep < 1) moves.push({ key: launch.key, wallet, block: block + 50_000, logIndex: n, tx: `0xsell${n}`, delta: -((tokenRaw * BigInt(Math.round((1 - keep) * 1000))) / 1000n) });
return { swap, first, moves };
}
function build(buyers: ReturnType[], over: Partial = {}) {
@@ -88,14 +89,14 @@ test("only the best 3 tokens a wallet launched on one day count, and each holder
test("dump: sold this season and dropped below half of what was bought → 0; selling only the fee tokens is fine", () => {
const b = buyer(w(1), START + DAY, 0.01);
- const sell: ScoreSwap = { key: L.key, trader: CREATOR, isBuy: false, quoteRaw: eth(0.1), tokenRaw: 600_000n * E18, block: 3000, logIndex: 10, time: START + 2 * DAY };
- const bought = new Map([[L.key, 1_000_000n * E18]]);
- const boughtIn: Move = { key: L.key, wallet: CREATOR, block: 50, logIndex: 1, delta: 1_000_000n * E18 };
- const sold: Move = { key: L.key, wallet: CREATOR, block: 3000, logIndex: 11, delta: -600_000n * E18 };
- const dumped = scoreSeason(build([b], { swaps: [b.swap, sell], launcherBought: bought, moves: [...b.moves, boughtIn, sold] }));
+ const sell: ScoreSwap = { key: L.key, trader: CREATOR, isBuy: false, quoteRaw: eth(0.1), tokenRaw: 600_000n * E18, block: 3000, logIndex: 10, time: START + 2 * DAY, tx: "0xcs" };
+ const bought = new Map([[L.key, [{ tx: "0xcb", tokenRaw: 1_000_000n * E18 }]]]);
+ const boughtIn: Move = { key: L.key, wallet: CREATOR, block: 50, logIndex: 1, tx: "0xcb", delta: 1_000_000n * E18 };
+ const sold: Move = { key: L.key, wallet: CREATOR, block: 3000, logIndex: 11, tx: "0xcs", delta: -600_000n * E18 };
+ const dumped = scoreSeason(build([b], { swaps: [b.swap, sell], launcherBuys: bought, moves: [...b.moves, boughtIn, sold] }));
assert.equal(dumped.wallets.get(CREATOR)?.creator ?? 0, 0);
- const feeIn: Move = { key: L.key, wallet: CREATOR, block: 2000, logIndex: 1, delta: 600_000n * E18 }; // fee tokens paid by the locker
- const feeSale = scoreSeason(build([b], { swaps: [b.swap, sell], launcherBought: bought, moves: [...b.moves, boughtIn, feeIn, sold] }));
+ const feeIn: Move = { key: L.key, wallet: CREATOR, block: 2000, logIndex: 1, tx: "0xfee", delta: 600_000n * E18 }; // fee tokens paid by the locker
+ const feeSale = scoreSeason(build([b], { swaps: [b.swap, sell], launcherBuys: bought, moves: [...b.moves, boughtIn, feeIn, sold] }));
assert.ok((feeSale.wallets.get(CREATOR)?.creator ?? 0) > 0, "sold the fee tokens, kept everything bought");
});
@@ -126,13 +127,13 @@ test("holds: first buy ≥ $5 this season, half held throughout for a day; 20 to
test("scout fees: held buys on tokens with 20+ real holders (5+ eligible), at most 200 a day; round trips earn nothing", () => {
const holders = Array.from({ length: 20 }, (_, i) => buyer(w(100 + i), START + DAY, 0.003));
const eligible = new Set(holders.slice(0, 5).map((h) => h.first.wallet));
- const buys: ScoreSwap[] = Array.from({ length: 30 }, (_, i) => ({ key: L.key, trader: w(1), isBuy: true, quoteRaw: eth(1), tokenRaw: tokensFor(1), block: 6000 + i, logIndex: 10, time: START + 2 * DAY + i }));
- const ins: Move[] = buys.map((b) => ({ key: L.key, wallet: w(1), block: b.block, logIndex: 11, delta: b.tokenRaw }));
+ const buys: ScoreSwap[] = Array.from({ length: 30 }, (_, i) => ({ key: L.key, trader: w(1), isBuy: true, quoteRaw: eth(1), tokenRaw: tokensFor(1), block: 6000 + i, logIndex: 10, time: START + 2 * DAY + i, tx: `0xsb${i}` }));
+ const ins: Move[] = buys.map((b) => ({ key: L.key, wallet: w(1), block: b.block, logIndex: 11, tx: b.tx, delta: b.tokenRaw }));
const base = { swaps: [...holders.map((h) => h.swap), ...buys], firstBuys: holders.map((h) => h.first), moves: [...holders.flatMap((h) => h.moves), ...ins], eligible };
const kept = scoreSeason(input(base)).wallets.get(w(1))!;
assert.ok(kept.scoutWhy.feesUsd > 0);
assert.ok(kept.scout <= RULES.scoutFeeCapPerDay + RULES.hold);
- const roundTrip = scoreSeason(input({ ...base, moves: [...base.moves, { key: L.key, wallet: w(1), block: 9000, logIndex: 0, delta: -tokensFor(1) * 30n }] }));
+ const roundTrip = scoreSeason(input({ ...base, moves: [...base.moves, { key: L.key, wallet: w(1), block: 9000, logIndex: 0, tx: "0xsellall", delta: -tokensFor(1) * 30n }] }));
assert.equal(roundTrip.wallets.get(w(1))?.scoutWhy.feesUsd ?? 0, 0, "sold it all again: no fee points for any of those buys");
assert.equal(scoreSeason(input({ ...base, eligible: new Set() })).wallets.get(w(1))?.scoutWhy.feesUsd ?? 0, 0, "fewer than 5 eligible holders");
assert.equal(scoreSeason(input({ swaps: buys, moves: ins })).wallets.get(w(1))?.scoutWhy.feesUsd ?? 0, 0, "no real holders");
@@ -147,7 +148,7 @@ test("farm: an unpriced quote (a farmer's own ERC-20) earns nothing at all", ()
test("farm: dust sent to 50 wallets makes none of them holders and unlocks nothing", () => {
const scout = buyer(w(1), START + DAY, 0.0025, 0.0001);
- const dust: Move[] = Array.from({ length: 50 }, (_, i) => ({ key: L.key, wallet: w(1000 + i), block: 70_000, logIndex: i, delta: 1n }));
+ const dust: Move[] = Array.from({ length: 50 }, (_, i) => ({ key: L.key, wallet: w(1000 + i), block: 70_000, logIndex: i, tx: `0xdust${i}`, delta: 1n }));
const r = scoreSeason(build([scout], { moves: [...scout.moves, ...dust], eligible: new Set([w(1)]) }));
assert.equal(r.realHolders.get(L.key) ?? 0, 0);
assert.equal(r.wallets.get(w(1))?.scout ?? 0, 0);
@@ -158,8 +159,8 @@ test("farm: wash trading earns nothing, verified or not; a verified buyer holdin
const moves: Move[] = [];
for (let i = 0; i < 100; i++) {
const buy = i % 2 === 0;
- swaps.push({ key: L.key, trader: "0xpuppet", isBuy: buy, quoteRaw: eth(10), tokenRaw: tokensFor(10), block: 1000 + i, logIndex: 10, time: START + 2 * DAY + i * 1000 });
- moves.push({ key: L.key, wallet: "0xpuppet", block: 1000 + i, logIndex: 11, delta: buy ? tokensFor(10) : -tokensFor(10) });
+ swaps.push({ key: L.key, trader: "0xpuppet", isBuy: buy, quoteRaw: eth(10), tokenRaw: tokensFor(10), block: 1000 + i, logIndex: 10, time: START + 2 * DAY + i * 1000, tx: `0xw${i}` });
+ moves.push({ key: L.key, wallet: "0xpuppet", block: 1000 + i, logIndex: 11, tx: `0xw${i}`, delta: buy ? tokensFor(10) : -tokensFor(10) });
}
assert.equal(scoreSeason(input({ swaps, moves })).wallets.get(CREATOR)?.creator ?? 0, 0);
assert.equal(scoreSeason(input({ swaps, moves, eligible: new Set(["0xpuppet"]) })).wallets.get(CREATOR)?.creator ?? 0, 0, "round trips by a verified wallet");
@@ -170,7 +171,7 @@ test("farm: wash trading earns nothing, verified or not; a verified buyer holdin
test("farm: topping a wallet back up by transfer before the final count restores nothing", () => {
// bought $5, sold out, then another wallet sends tokens back just before the count
const b = buyer(w(1), START + DAY, 0.0025, 0);
- const topUp: Move = { key: L.key, wallet: w(1), block: 900_000, logIndex: 0, delta: tokensFor(0.0025) * 10n };
+ const topUp: Move = { key: L.key, wallet: w(1), block: 900_000, logIndex: 0, tx: "0xtopup", delta: tokensFor(0.0025) * 10n };
const r = scoreSeason(build([b], { moves: [...b.moves, topUp], eligible: new Set([w(1)]) }));
assert.equal(r.realHolders.get(L.key) ?? 0, 0);
assert.equal(r.wallets.get(CREATOR)?.creator ?? 0, 0);
@@ -181,7 +182,7 @@ test("farm: $5 passed from puppet to puppet counts for none of them", () => {
const puppets = Array.from({ length: 30 }, (_, i) => {
const b = buyer(w(200 + i), START + DAY + i * 1000, 0.0025);
// each puppet hands its tokens on to a collector right after buying
- b.moves.push({ key: L.key, wallet: w(200 + i), block: b.swap.block + 1, logIndex: 0, delta: -b.swap.tokenRaw });
+ b.moves.push({ key: L.key, wallet: w(200 + i), block: b.swap.block + 1, logIndex: 0, tx: `0xrelay${i}`, delta: -b.swap.tokenRaw });
return b;
});
const r = scoreSeason(build(puppets));
@@ -189,6 +190,30 @@ test("farm: $5 passed from puppet to puppet counts for none of them", () => {
assert.equal(r.wallets.get(CREATOR)?.creator ?? 0, 0);
});
+test("farm: a small base position held all season does not cover later round trips", () => {
+ // $5 bought and held, then 50 same-day round trips of $5 each: each sell uses up the lot it just bought
+ const holders = Array.from({ length: 20 }, (_, i) => buyer(w(100 + i), START + DAY, 0.003));
+ const eligible = new Set([w(1), ...holders.slice(0, 5).map((h) => h.first.wallet)]);
+ const base = buyer(w(1), START + DAY, 0.0025);
+ const swaps: ScoreSwap[] = [];
+ const moves: Move[] = [];
+ for (let i = 0; i < 100; i++) {
+ const buy = i % 2 === 0;
+ swaps.push({ key: L.key, trader: w(1), isBuy: buy, quoteRaw: eth(0.0025), tokenRaw: tokensFor(0.0025), block: 20_000 + i, logIndex: 10, time: START + 2 * DAY + i * 1000, tx: `0xrt${i}` });
+ moves.push({ key: L.key, wallet: w(1), block: 20_000 + i, logIndex: 11, tx: `0xrt${i}`, delta: buy ? tokensFor(0.0025) : -tokensFor(0.0025) });
+ }
+ const r = scoreSeason(build([...holders, base], { swaps: [...holders.map((h) => h.swap), base.swap, ...swaps], moves: [...holders.flatMap((h) => h.moves), ...base.moves, ...moves], eligible }));
+ assert.ok((r.wallets.get(w(1))?.scoutWhy.feesUsd ?? 0) <= 0.06, `only the base buy's fee may count, got ${r.wallets.get(w(1))?.scoutWhy.feesUsd}`);
+});
+
+test("a buy whose token transfer is logged before its swap (router takes first) still counts", () => {
+ const b = buyer(w(1), START + DAY, 0.01);
+ b.moves[0] = { ...b.moves[0], logIndex: b.swap.logIndex - 1 };
+ const r = scoreSeason(build([b]));
+ assert.equal(r.realHolders.get(L.key), 1);
+ assert.equal(r.wallets.get(w(1))!.scoutWhy.holds, 1);
+});
+
test("farm: a wallet kept off points (deleted profile or not) scores nothing and counts for no one", () => {
const b = buyer(w(1), START + DAY, 0.01);
const r = scoreSeason(build([b], { flagged: new Set([w(1)]), eligible: new Set([w(1)]) }));
diff --git a/app/src/lib/points/score.ts b/app/src/lib/points/score.ts
index 6cc0a2eb..4bb86372 100644
--- a/app/src/lib/points/score.ts
+++ b/app/src/lib/points/score.ts
@@ -7,10 +7,12 @@
* real buyer = a wallet whose FIRST buy of a token was ≥ $5, outside the sniper window (the launch block + 3, and
* the first 12 seconds), and that is not the launcher, a fee recipient, a protocol address, a wallet
* that got the token by transfer from the launcher or a recipient, or a wallet kept off points
- * real holder = a real buyer who has held ≥ $5 of it (≥ $1 if their profile is points-eligible) ever since that buy
+ * real holder = a real buyer with ≥ $5 (≥ $1 if their profile is points-eligible) of that first buy's lot still left
*
- * "Held" always means the LOWEST balance since the buy in question, read from the transfer history: tokens sent in
- * later by another wallet cannot top a position back up, and $5 passed from wallet to wallet counts for none of them.
+ * "Held" is judged per buy, from the transfer history: every incoming transfer is a lot, and every outgoing one (a sell,
+ * or a transfer out to any wallet) uses up the newest lots first. A buy is held while at least half of its own lot is
+ * left. So a round trip uses up the lot it just bought, tokens sent in later by another wallet are new lots that cover
+ * no earlier buy, and $5 passed from wallet to wallet counts for none of them. Moving tokens out counts as selling.
*
* Only tokens whose quote has a price count at all. Then:
*
@@ -19,7 +21,7 @@
* else 5; a week or more ago: half again. A holder counts once per creator (their best token).
* - 10 per $1 of fees on buys ≥ $5 by points-eligible outside traders, for each buy half of which has been held ever
* since (a round trip earns nothing); at most 50 per trader per creator per day
- * - a token scores 0 if its creator sold during the season and has since dropped below half of what they bought
+ * - a token scores 0 if its creator sold during the season and less than half of their own buys is left
* - of the tokens a wallet launched on one UTC day, only the best 3 count
* Scout (traders)
* - one of the first 25 real buyers of a token that now has 50+ real holders, buying in the season: 100, and 50
@@ -71,9 +73,9 @@ export type ScoreLaunch = {
quoteUsd: number | null; // USD per whole quote unit; null = unpriced → the token is out of scoring
tokenUsd: number | null; // USD per whole token now; null → out of scoring
};
-export type ScoreSwap = { key: string; trader: string; isBuy: boolean; quoteRaw: bigint; tokenRaw: bigint; block: number; logIndex: number; time: number };
+export type ScoreSwap = { key: string; trader: string; isBuy: boolean; quoteRaw: bigint; tokenRaw: bigint; block: number; logIndex: number; time: number; tx: string };
/** A wallet's first buy of a token, all-time (its rank among real buyers decides "early"). */
-export type FirstBuy = { key: string; wallet: string; block: number; logIndex: number; time: number; quoteRaw: bigint; tokenRaw: bigint };
+export type FirstBuy = { key: string; wallet: string; block: number; logIndex: number; time: number; quoteRaw: bigint; tokenRaw: bigint; tx: string };
export type ScoreInput = {
seasonStart: number;
@@ -84,9 +86,9 @@ export type ScoreInput = {
swaps: readonly ScoreSwap[];
firstBuys: readonly FirstBuy[];
/** every balance change (raw, 18 decimals) of buyers and launchers of the tokens in scope, from the transfer history */
- moves: readonly { key: string; wallet: string; block: number; logIndex: number; delta: bigint }[];
- /** the launcher's all-time bought amount (raw) per token */
- launcherBought: ReadonlyMap;
+ moves: readonly { key: string; wallet: string; block: number; logIndex: number; tx: string; delta: bigint }[];
+ /** the launcher's own buys of each token, all-time (transaction + tokens bought) */
+ launcherBuys: ReadonlyMap;
/** per token: wallets that received it by transfer from the launcher or a fee recipient */
linked: ReadonlyMap>;
/** protocol addresses (pool manager, routers, locker, factory, zero, dead) */
@@ -122,38 +124,47 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
const quoteUsd = (l: ScoreLaunch, raw: bigint) => units(raw, l.quoteDecimals) * (l.quoteUsd as number);
const tokenUsd = (l: ScoreLaunch, raw: bigint) => units(raw, 18) * (l.tokenUsd as number);
const inSeason = (t: number) => t >= input.seasonStart && t < end;
- // balance timelines per (token, wallet): the running balance after each transfer, in chain order
- type Point = { block: number; logIndex: number; balance: bigint };
- const timelines = new Map();
+ // lots per (token, wallet): each incoming transfer is a lot; each outgoing one uses up the newest lots first (LIFO).
+ // A lot only ever shrinks, so what is left of it at the end is the least it ever held. Linear in the moves.
+ const lotsByTx = new Map(); // `${key}|${wallet}|${tx}` → what is left of the lots that tx brought in
{
- const net = new Map>();
+ const byPair = new Map>();
for (const m of input.moves) {
const k = `${m.key}|${m.wallet}`;
const at = `${m.block}:${m.logIndex}`;
- const byAt = net.get(k) ?? new Map();
+ const byAt = byPair.get(k) ?? new Map();
const cur = byAt.get(at);
- byAt.set(at, { block: m.block, logIndex: m.logIndex, delta: (cur?.delta ?? 0n) + m.delta }); // a self-transfer nets to 0
- net.set(k, byAt);
+ byAt.set(at, { block: m.block, logIndex: m.logIndex, tx: m.tx, delta: (cur?.delta ?? 0n) + m.delta }); // a self-transfer nets to 0
+ byPair.set(k, byAt);
}
- for (const [k, byAt] of net) {
- let balance = 0n;
- const pts: Point[] = [...byAt.values()].sort((a, b) => a.block - b.block || a.logIndex - b.logIndex).map((x) => ({ block: x.block, logIndex: x.logIndex, balance: (balance += x.delta) }));
- timelines.set(k, pts);
+ for (const [k, byAt] of byPair) {
+ const stack: { tx: string; left: bigint }[] = [];
+ const all: { tx: string; left: bigint }[] = [];
+ for (const m of [...byAt.values()].sort((a, b) => a.block - b.block || a.logIndex - b.logIndex)) {
+ if (m.delta > 0n) {
+ const lot = { tx: m.tx, left: m.delta };
+ stack.push(lot);
+ all.push(lot);
+ } else if (m.delta < 0n) {
+ let out = -m.delta;
+ while (out > 0n && stack.length) {
+ const top = stack[stack.length - 1];
+ const take = top.left < out ? top.left : out;
+ top.left -= take;
+ out -= take;
+ if (top.left === 0n) stack.pop();
+ }
+ }
+ }
+ for (const lot of all) lotsByTx.set(`${k}|${lot.tx}`, (lotsByTx.get(`${k}|${lot.tx}`) ?? 0n) + lot.left);
}
}
- /** The lowest balance after the given position (a swap: its token transfer comes right after it). 0 when unknown. */
- const minAfter = (key: string, w: string, block: number, logIndex: number): bigint => {
- let min: bigint | null = null;
- for (const p of timelines.get(`${key}|${w}`) ?? []) {
- if (p.block < block || (p.block === block && p.logIndex <= logIndex)) continue;
- if (min === null || p.balance < min) min = p.balance;
- }
- return min !== null && min > 0n ? min : 0n;
- };
- /** Held at least half of a buy, every moment since it. */
- const halfHeldSince = (key: string, w: string, block: number, logIndex: number, boughtRaw: bigint) => {
- const m = minAfter(key, w, block, logIndex);
- return m > 0n && m * 2n >= boughtRaw;
+ /** What is left of the tokens a transaction brought into a wallet (matched by transaction, not log order). */
+ const leftOf = (key: string, w: string, tx: string) => lotsByTx.get(`${key}|${w}|${tx}`) ?? 0n;
+ /** At least half of a buy's own lot left. */
+ const halfLeft = (key: string, w: string, tx: string, boughtRaw: bigint) => {
+ const left = leftOf(key, w, tx);
+ return left > 0n && left * 2n >= boughtRaw;
};
// real buyers per token, in buy order
@@ -171,28 +182,25 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
const realHolders = new Map();
for (const [key, list] of realBuyers) {
const l = launches.get(key)!;
- const held = list.filter((f) => tokenUsd(l, minAfter(key, f.wallet, f.block, f.logIndex)) >= (input.eligible.has(f.wallet) ? RULES.verifiedHolderMinUsd : RULES.holderMinUsd));
+ const held = list.filter((f) => tokenUsd(l, leftOf(key, f.wallet, f.tx)) >= (input.eligible.has(f.wallet) ? RULES.verifiedHolderMinUsd : RULES.holderMinUsd));
realHolders.set(key, held);
}
const holderCount = (key: string) => realHolders.get(key)?.length ?? 0;
// the season's outside trades (≥ $5, outside the sniper window), and what each creator sold
const outside: ScoreSwap[] = [];
- const creatorFirstSell = new Map();
+ const creatorSold = new Set();
for (const s of input.swaps) {
const l = launches.get(s.key);
if (!l || !inSeason(s.time)) continue;
- if (s.trader === l.launcher && !s.isBuy) {
- const cur = creatorFirstSell.get(s.key);
- if (!cur || s.block < cur.block || (s.block === cur.block && s.logIndex < cur.logIndex)) creatorFirstSell.set(s.key, s);
- }
+ if (s.trader === l.launcher && !s.isBuy) creatorSold.add(s.key);
if (insider(l, s.trader) || sniper(l, s.block, s.time) || quoteUsd(l, s.quoteRaw) < RULES.minTradeUsd) continue;
outside.push(s);
}
// net buying: a buy earns fee points only if at least half of it has been held every moment since, so a round trip
// (the fees coming back to a farmer's own token) earns nothing and washing ties up real capital for the season
- const heldBuy = (s: ScoreSwap) => halfHeldSince(s.key, s.trader, s.block, s.logIndex, s.tokenRaw);
+ const heldBuy = (s: ScoreSwap) => halfLeft(s.key, s.trader, s.tx, s.tokenRaw);
const eligibleHolders = new Map();
for (const [key, list] of realHolders) eligibleHolders.set(key, list.filter((f) => input.eligible.has(f.wallet)).length);
@@ -224,11 +232,12 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
if (fee > 0) ensure(l).fees.push({ trader: s.trader, day: utcDay(s.time), usd: fee });
}
for (const t of perToken.values()) {
- const bought = input.launcherBought.get(t.l.key) ?? 0n;
- const sell = creatorFirstSell.get(t.l.key);
- // a dump = sold during the season and dropped below half of what was bought at any point since (selling the fee
- // tokens the pool paid out, while keeping what was bought, is not one)
- if (bought > 0n && sell && minAfter(t.l.key, t.l.launcher, sell.block, sell.logIndex) * 2n < bought) t.dumped = true;
+ const buys = input.launcherBuys.get(t.l.key) ?? [];
+ const bought = buys.reduce((a, b) => a + b.tokenRaw, 0n);
+ const left = buys.reduce((a, b) => a + leftOf(t.l.key, t.l.launcher, b.tx), 0n);
+ // a dump = sold during the season and less than half of the creator's own buys is left (fee tokens that came in
+ // after the buys are used up first, so selling those is not one)
+ if (bought > 0n && creatorSold.has(t.l.key) && left * 2n < bought) t.dumped = true;
}
// per creator: each holder once (their best token), fees capped per trader per day, best 3 tokens per launch day
const byCreator = new Map();
@@ -314,7 +323,7 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
for (const f of list.slice(0, RULES.earlyFirstBuyers)) {
if (!inSeason(f.time)) continue;
const s = get(f.wallet);
- s.scout += RULES.early + (halfHeldSince(key, f.wallet, f.block, f.logIndex, f.tokenRaw) ? RULES.earlyStillHolding : 0);
+ s.scout += RULES.early + (halfLeft(key, f.wallet, f.tx, f.tokenRaw) ? RULES.earlyStillHolding : 0);
s.scoutWhy.early++;
}
}
diff --git a/app/src/lib/points/server.ts b/app/src/lib/points/server.ts
index 6720a1e4..08f68b65 100644
--- a/app/src/lib/points/server.ts
+++ b/app/src/lib/points/server.ts
@@ -67,8 +67,8 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis
const price = await quotePricer(usd);
// the season's swaps decide which tokens are in scope
- const swapRows = await read<{ chain_id: number; token: string; trader: string; is_buy: boolean; amount0: string; amount1: string; block_number: bigint; log_index: number; t: string }[]>`
- SELECT chain_id, token, trader, is_buy, amount0, amount1, block_number, log_index, block_time AS t
+ const swapRows = await read<{ chain_id: number; token: string; trader: string; is_buy: boolean; amount0: string; amount1: string; block_number: bigint; log_index: number; tx_hash: string; t: string }[]>`
+ SELECT chain_id, token, trader, is_buy, amount0, amount1, block_number, log_index, tx_hash, block_time AS t
FROM bb_launch_swaps WHERE block_time >= ${season.starts_at} AND block_time < ${until} AND trader IS NOT NULL`;
const keyOf = (cid: number, token: string) => `${chainKeyOf(cid) ?? cid}:${token}`;
const scope = new Map();
@@ -106,8 +106,8 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis
});
}
- const firstRows = await read<{ chain_id: number; token: string; trader: string; block_number: bigint; log_index: number; t: string; amount0: string; amount1: string }[]>`
- SELECT DISTINCT ON (s.chain_id, s.token, s.trader) s.chain_id, s.token, s.trader, s.block_number, s.log_index, s.block_time AS t, s.amount0, s.amount1
+ const firstRows = await read<{ chain_id: number; token: string; trader: string; block_number: bigint; log_index: number; tx_hash: string; t: string; amount0: string; amount1: string }[]>`
+ SELECT DISTINCT ON (s.chain_id, s.token, s.trader) s.chain_id, s.token, s.trader, s.block_number, s.log_index, s.tx_hash, s.block_time AS t, s.amount0, s.amount1
FROM bb_launch_swaps s JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON s.chain_id = u.cid AND s.token = u.tok
WHERE s.is_buy AND s.trader IS NOT NULL ORDER BY s.chain_id, s.token, s.trader, s.block_number, s.log_index`;
// every balance change of every buyer (and launcher) of the tokens in scope: holding is judged on the lowest balance
@@ -116,18 +116,18 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis
for (const f of firstRows) pairs.set(`${f.chain_id}|${f.token}|${f.trader}`, { cid: f.chain_id, tok: f.token, w: f.trader });
for (const l of launchRows) pairs.set(`${l.chain_id}|${l.token}|${l.launcher}`, { cid: l.chain_id, tok: l.token, w: l.launcher });
const pv = [...pairs.values()];
- const moveRows = await read<{ chain_id: number; token: string; wallet: string; block_number: bigint; log_index: number; delta: string }[]>`
+ const moveRows = await read<{ chain_id: number; token: string; wallet: string; block_number: bigint; log_index: number; tx_hash: string; delta: string }[]>`
WITH p AS (SELECT * FROM unnest(${pv.map((x) => x.cid)}::int[], ${pv.map((x) => x.tok)}::text[], ${pv.map((x) => x.w)}::text[]) AS p(cid, tok, w))
- SELECT t.chain_id, t.token, p.w AS wallet, t.block_number, t.log_index, t.value::text AS delta
+ SELECT t.chain_id, t.token, p.w AS wallet, t.block_number, t.log_index, t.tx_hash, t.value::text AS delta
FROM bb_token_transfers t JOIN p ON t.chain_id = p.cid AND t.token = p.tok AND t.to_addr = p.w
UNION ALL
- SELECT t.chain_id, t.token, p.w AS wallet, t.block_number, t.log_index, (-t.value)::text AS delta
+ SELECT t.chain_id, t.token, p.w AS wallet, t.block_number, t.log_index, t.tx_hash, (-t.value)::text AS delta
FROM bb_token_transfers t JOIN p ON t.chain_id = p.cid AND t.token = p.tok AND t.from_addr = p.w`;
- const boughtRows = await read<{ chain_id: number; token: string; bought: string }[]>`
- SELECT s.chain_id, s.token, sum(s.amount1)::text AS bought
+ const launcherBuyRows = await read<{ chain_id: number; token: string; tx_hash: string; amount1: string }[]>`
+ SELECT s.chain_id, s.token, s.tx_hash, s.amount1
FROM bb_launch_swaps s JOIN bb_launches l ON l.chain_id = s.chain_id AND l.token = s.token
JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON s.chain_id = u.cid AND s.token = u.tok
- WHERE s.is_buy AND s.trader = l.launcher GROUP BY s.chain_id, s.token`;
+ WHERE s.is_buy AND s.trader = l.launcher`;
const linkedRows = await read<{ chain_id: number; token: string; to_addr: string }[]>`
SELECT DISTINCT t.chain_id, t.token, t.to_addr
FROM bb_token_transfers t JOIN bb_launches l ON l.chain_id = t.chain_id AND l.token = t.token
@@ -139,8 +139,13 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis
const abs = (v: string) => (v.startsWith("-") ? BigInt(v.slice(1)) : BigInt(v));
const int = (v: string) => abs(v.split(".")[0]);
- const swaps: ScoreSwap[] = swapRows.map((s) => ({ key: keyOf(s.chain_id, s.token), trader: s.trader, isBuy: s.is_buy, quoteRaw: abs(s.amount0), tokenRaw: abs(s.amount1), block: Number(s.block_number), logIndex: Number(s.log_index), time: new Date(s.t).getTime() }));
- const firstBuys: FirstBuy[] = firstRows.map((f) => ({ key: keyOf(f.chain_id, f.token), wallet: f.trader, block: Number(f.block_number), logIndex: Number(f.log_index), time: new Date(f.t).getTime(), quoteRaw: abs(f.amount0), tokenRaw: abs(f.amount1) }));
+ const swaps: ScoreSwap[] = swapRows.map((s) => ({ key: keyOf(s.chain_id, s.token), trader: s.trader, isBuy: s.is_buy, quoteRaw: abs(s.amount0), tokenRaw: abs(s.amount1), block: Number(s.block_number), logIndex: Number(s.log_index), time: new Date(s.t).getTime(), tx: s.tx_hash }));
+ const firstBuys: FirstBuy[] = firstRows.map((f) => ({ key: keyOf(f.chain_id, f.token), wallet: f.trader, block: Number(f.block_number), logIndex: Number(f.log_index), time: new Date(f.t).getTime(), quoteRaw: abs(f.amount0), tokenRaw: abs(f.amount1), tx: f.tx_hash }));
+ const launcherBuys = new Map();
+ for (const b of launcherBuyRows) {
+ const k = keyOf(b.chain_id, b.token);
+ launcherBuys.set(k, [...(launcherBuys.get(k) ?? []), { tx: b.tx_hash, tokenRaw: abs(b.amount1) }]);
+ }
const linked = new Map>();
for (const r of linkedRows) {
const k = keyOf(r.chain_id, r.token);
@@ -166,8 +171,8 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis
launches,
swaps,
firstBuys,
- moves: moveRows.map((m) => ({ key: keyOf(m.chain_id, m.token), wallet: m.wallet, block: Number(m.block_number), logIndex: Number(m.log_index), delta: m.delta.startsWith("-") ? -int(m.delta) : int(m.delta) })),
- launcherBought: new Map(boughtRows.map((b) => [keyOf(b.chain_id, b.token), int(b.bought)])),
+ moves: moveRows.map((m) => ({ key: keyOf(m.chain_id, m.token), wallet: m.wallet, block: Number(m.block_number), logIndex: Number(m.log_index), tx: m.tx_hash, delta: m.delta.startsWith("-") ? -int(m.delta) : int(m.delta) })),
+ launcherBuys,
linked,
system,
eligible,
From 8022e1705dde4e9b4e9cb77de7f6c78d0029861c Mon Sep 17 00:00:00 2001
From: Kevin Codex
Date: Thu, 8 Oct 2026 12:51:35 +0800
Subject: [PATCH 06/13] fix(points): per-wallet points state never crosses a
wallet switch
Same rule as the profile components: usePoints tags its answer with the wallet
it was read for and returns nothing for any other wallet, so after a switch in
the wallet app the previous wallet's standing is never shown while the new
one loads (or if its lookup fails). The alive check now runs after the body
is read. The admin preview is shown only for the admin wallet that signed it,
and an action or preview finishing after a switch is dropped.
Browser check on /leaderboard: switch from a wallet with points to one whose
lookup is held back 3 s; the old code showed the first wallet's standing for
the second, the new code shows none.
---
app/src/components/points/PointsAdmin.tsx | 23 +++++++++++++++++------
app/src/components/points/usePoints.ts | 19 +++++++++++++------
2 files changed, 30 insertions(+), 12 deletions(-)
diff --git a/app/src/components/points/PointsAdmin.tsx b/app/src/components/points/PointsAdmin.tsx
index 830ffa2f..c02863b8 100644
--- a/app/src/components/points/PointsAdmin.tsx
+++ b/app/src/components/points/PointsAdmin.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useState } from "react";
+import { useEffect, useRef, useState } from "react";
import { useAccount, useConfig } from "wagmi";
import { getWalletClient } from "wagmi/actions";
import { useHydratedAccount } from "@/lib/useHydratedAccount";
@@ -29,9 +29,18 @@ export default function PointsAdmin() {
const { chainId } = useAccount();
const config = useConfig();
const chain: ChainKey = (Object.keys(CHAINS) as ChainKey[]).find((k) => CHAINS[k].id === chainId) ?? DEFAULT_CHAIN;
- const [data, setData] = useState(null);
- const [err, setErr] = useState(null);
+ // the preview is read with one admin wallet's signature and shown only while that wallet is connected; an action
+ // or preview that finishes after a switch is dropped
+ const me = address?.toLowerCase() ?? "";
+ const [loaded, setLoaded] = useState<{ wallet: string; v: Preview } | null>(null);
+ const [failed, setFailed] = useState<{ wallet: string; message: string } | null>(null);
const [busy, setBusy] = useState(false);
+ const data = loaded?.wallet === me ? loaded.v : null;
+ const err = failed?.wallet === me ? failed.message : null;
+ const current = useRef(me);
+ useEffect(() => {
+ current.current = me;
+ }, [me]);
async function call(action: PointsAdminAction, extra: Record = {}) {
if (!address) return null;
@@ -45,13 +54,15 @@ export default function PointsAdmin() {
return d;
}
async function run(action: PointsAdminAction, extra: Record = {}) {
+ const who = me;
setBusy(true);
- setErr(null);
+ setFailed(null);
try {
if (action !== "preview") await call(action, extra);
- setData({ ...((await call("preview")) as Omit), at: Date.now() });
+ const v = { ...((await call("preview")) as Omit), at: Date.now() };
+ if (current.current === who) setLoaded({ wallet: who, v });
} catch (e) {
- setErr(friendlyError(e));
+ if (current.current === who) setFailed({ wallet: who, message: friendlyError(e) });
} finally {
setBusy(false);
}
diff --git a/app/src/components/points/usePoints.ts b/app/src/components/points/usePoints.ts
index 4e89f5e5..61fedfdf 100644
--- a/app/src/components/points/usePoints.ts
+++ b/app/src/components/points/usePoints.ts
@@ -6,15 +6,22 @@ import type { NotEligibleReason } from "@/lib/points/score";
export type PointsState = { season: { name: string; starts_at: string; ends_at: string; ended: boolean } | null; me: WalletPoints | null; reason: NotEligibleReason | null; at: number } | null;
-/** The public season and this wallet's points (null season while the season is not public). */
+/**
+ * The public season and this wallet's points (null season while the season is not public). The answer is tagged with
+ * the wallet it was read for: after a switch in the wallet app the previous wallet's standing is never returned (null
+ * until the new one answers), and an answer that arrives after the switch is dropped.
+ */
export function usePoints(wallet: string | undefined): PointsState {
- const [state, setState] = useState(null);
+ const me = wallet?.toLowerCase() ?? "";
+ const [state, setState] = useState<{ wallet: string; v: NonNullable } | null>(null);
useEffect(() => {
let alive = true;
const id = setTimeout(async () => {
try {
- const r = await fetch(`/api/points${wallet ? `?wallet=${wallet}` : ""}`, { cache: "no-store" });
- if (r.ok && alive) setState({ ...((await r.json()) as Omit, "at">), at: Date.now() });
+ const r = await fetch(`/api/points${me ? `?wallet=${me}` : ""}`, { cache: "no-store" });
+ if (!r.ok) return;
+ const v = { ...((await r.json()) as Omit, "at">), at: Date.now() };
+ if (alive) setState({ wallet: me, v }); // checked after the body is read, not before
} catch {
/* points are a side panel: say nothing on a blip */
}
@@ -23,6 +30,6 @@ export function usePoints(wallet: string | undefined): PointsState {
alive = false;
clearTimeout(id);
};
- }, [wallet]);
- return state;
+ }, [me]);
+ return state && state.wallet === me ? state.v : null;
}
From 650c548f556e51079195ddbec6a4e196ce766fde Mon Sep 17 00:00:00 2001
From: Kevin Codex
Date: Fri, 9 Oct 2026 10:00:35 +0800
Subject: [PATCH 07/13] refactor(points): the admin panel uses the shared nonce
helper
---
app/src/components/points/PointsAdmin.tsx | 7 +------
1 file changed, 1 insertion(+), 6 deletions(-)
diff --git a/app/src/components/points/PointsAdmin.tsx b/app/src/components/points/PointsAdmin.tsx
index c02863b8..5543508d 100644
--- a/app/src/components/points/PointsAdmin.tsx
+++ b/app/src/components/points/PointsAdmin.tsx
@@ -10,16 +10,11 @@ import type { NameEntry } from "@/lib/profiles/server";
import { CHAINS, DEFAULT_CHAIN, shortAddr, type ChainKey } from "@/lib/chainPublic";
import { nowMs } from "@/lib/launchpad/time";
import { friendlyError } from "@/lib/errors";
+import { nonce } from "@/lib/nonce";
type Row = { rank: number; wallet: string; points: number; eligible: boolean; why: string };
type Preview = { season: { name: string; starts_at: string; ends_at: string; public: boolean; published_at: string | null } | null; creator?: Row[]; scout?: Row[]; names?: Record; computed_at?: string | null; wallets?: number; eligible?: number; at: number };
-function nonce(): string {
- const b = new Uint8Array(16);
- crypto.getRandomValues(b);
- return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
-}
-
/**
* Season controls for an admin: start a season (not public), see the shadow boards (eligible or not, with why), publish
* when the trial week looks right, recompute now, end early. Every action is an admin signature.
From 9fc7d9371c5f42c3f47180697d8ca3e9c4ee5def Mon Sep 17 00:00:00 2001
From: Kevin Codex
Date: Fri, 9 Oct 2026 13:35:53 +0800
Subject: [PATCH 08/13] fix(points): CodeRabbit on #85 (compute vs publish,
refused publish, season card, refresh, ending)
- A compute that read the season before a publish (or an end) writes
nothing: its write transaction locks the season row and re-reads it, and
if starts_at, ends_at or published_at changed it skips. Before, it could
put shadow-window rows back on the freshly published board and set
computed_at, so the next compute waited an hour.
- publishSeason's fallback only shows a season that went public before; a
shadow season that ended in the moment between the route's check and the
publish is "refused" (409) instead of made public.
- Ending asks once more and says what it means: "End season now" for a
public season (final standings, no restart), "Discard the hidden run" for
one that never went public (it can never be published after that).
- /me shows the season card in every dashboard state, so points still show
when the dashboard request fails (the same card instance as data arrives).
- usePoints reads again every 5 minutes while the page is open (not in
hidden tabs), so a publish or a new run reaches an open page, and retries
a failed read after 10, 30 and 60 s.
- One-line doc comments on the functions this PR touches.
---
app/src/app/api/points/admin/route.ts | 2 ++
app/src/app/leaderboard/page.tsx | 2 ++
app/src/app/rules/page.tsx | 1 +
app/src/components/launchpad/MeDashboard.tsx | 6 +++++
app/src/components/points/PointsAdmin.tsx | 17 +++++++++++-
app/src/components/points/usePoints.ts | 27 ++++++++++++++-----
app/src/lib/launchpad/queries.ts | 9 +++++++
app/src/lib/points/auth.ts | 2 ++
app/src/lib/points/score.ts | 4 +++
app/src/lib/points/server.ts | 28 ++++++++++++++++----
10 files changed, 86 insertions(+), 12 deletions(-)
diff --git a/app/src/app/api/points/admin/route.ts b/app/src/app/api/points/admin/route.ts
index a4152aa4..81a7d4b9 100644
--- a/app/src/app/api/points/admin/route.ts
+++ b/app/src/app/api/points/admin/route.ts
@@ -36,6 +36,8 @@ export async function POST(req: Request) {
// a shadow run that ended without ever going public has no standings anyone was told about
if (!season.published_at && seasonEnded(season)) return NextResponse.json({ error: `${season.name} ended without being published; start a new season` }, { status: 409 });
const r = await publishSeason(season.id);
+ // it ended in the moment between the check above and the publish
+ if (r === "refused") return NextResponse.json({ error: `${season.name} ended without being published; start a new season` }, { status: 409 });
if (r === "started") void recomputeNow().catch(() => {});
return NextResponse.json({ ok: true, result: r });
}
diff --git a/app/src/app/leaderboard/page.tsx b/app/src/app/leaderboard/page.tsx
index 3e180307..7fd0715f 100644
--- a/app/src/app/leaderboard/page.tsx
+++ b/app/src/app/leaderboard/page.tsx
@@ -12,10 +12,12 @@ import { pageMetadata } from "@/lib/seo";
export const dynamic = "force-dynamic";
export const metadata: Metadata = pageMetadata({ path: "/leaderboard", title: "Leaderboard", description: "Season points for creators whose tokens find real buyers and traders who find them early. Computed from the chain every hour." });
+/** Whole days until a season ends (0 once it has). */
function daysLeft(endsAt: string): number {
return Math.max(0, Math.ceil((new Date(endsAt).getTime() - Date.now()) / 86_400_000));
}
+/** The public season's Creators and Scouts boards, your own standing, or the "almost here" teaser before a season is public. */
export default async function LeaderboardPage({ searchParams }: { searchParams: Promise<{ board?: string }> }) {
const { board: raw } = await searchParams;
const board: Board = raw === "scout" ? "scout" : "creator";
diff --git a/app/src/app/rules/page.tsx b/app/src/app/rules/page.tsx
index d26c41ef..85252fc8 100644
--- a/app/src/app/rules/page.tsx
+++ b/app/src/app/rules/page.tsx
@@ -50,6 +50,7 @@ const VERIFIERS: Record<(typeof CHAIN_KEYS)[number], { name: string; url: (addr:
],
};
+/** The rules: launching, fees, posting, and how Season points are earned (no cash value, not a token). */
export default function RulesPage() {
return (
diff --git a/app/src/components/launchpad/MeDashboard.tsx b/app/src/components/launchpad/MeDashboard.tsx
index 7bcd0723..46e929fe 100644
--- a/app/src/components/launchpad/MeDashboard.tsx
+++ b/app/src/components/launchpad/MeDashboard.tsx
@@ -35,6 +35,7 @@ type Me = { wallet: string; ethUsd: number | null; launches: LaunchRow[]; tokens
type Pending = Record; // key chain:token → uncollected fees on both sides (raw), null = unknown
type Balances = Record;
+/** A launch's key in the balances map (chain and token). */
const key = (l: { chain: ChainKey; token: string }) => `${l.chain}:${l.token}`;
/**
@@ -199,9 +200,12 @@ function WalletDashboard({ address, isConnected }: { address: Address | undefine
;
if (!me) {
+ // the season card sits in the same place in every state (points come from their own endpoint, so they show even
+ // when the dashboard could not load), and React keeps the same card as the dashboard arrives
return (
+ {running && confirmEnd && s ? (
+ // ending is for good: say what it means before the signature, above all for a run that never went public
+
+
+ {s.published_at
+ ? `End ${s.name} now? Its final standings are computed once and stay on the board; it cannot be restarted.`
+ : `Discard ${s.name}'s hidden run? It never went public, so it can never be published after this: start a new season instead.`}
+
+
+
+
+
+
+ ) : null}
{s ?
{list("Creators (top 50, eligible or not)", data.creator)}{list("Scouts (top 50, eligible or not)", data.scout)}
: null}
>
diff --git a/app/src/components/points/usePoints.ts b/app/src/components/points/usePoints.ts
index 61fedfdf..f05ec389 100644
--- a/app/src/components/points/usePoints.ts
+++ b/app/src/components/points/usePoints.ts
@@ -6,26 +6,41 @@ import type { NotEligibleReason } from "@/lib/points/score";
export type PointsState = { season: { name: string; starts_at: string; ends_at: string; ended: boolean } | null; me: WalletPoints | null; reason: NotEligibleReason | null; at: number } | null;
+const REFRESH_MS = 5 * 60_000; // points are recomputed hourly; a page left open picks up a publish or a new run
+const RETRY_MS = [10_000, 30_000, 60_000]; // after a failed read, then back to the normal refresh
+
/**
* The public season and this wallet's points (null season while the season is not public). The answer is tagged with
* the wallet it was read for: after a switch in the wallet app the previous wallet's standing is never returned (null
- * until the new one answers), and an answer that arrives after the switch is dropped.
+ * until the new one answers), and an answer that arrives after the switch is dropped. While the page stays open it
+ * reads again every few minutes (skipped in hidden tabs), and a failed read is retried sooner.
*/
export function usePoints(wallet: string | undefined): PointsState {
const me = wallet?.toLowerCase() ?? "";
const [state, setState] = useState<{ wallet: string; v: NonNullable } | null>(null);
useEffect(() => {
let alive = true;
- const id = setTimeout(async () => {
+ let failures = 0;
+ let id: ReturnType;
+ const next = (ms: number) => {
+ if (alive) id = setTimeout(() => void read(), ms);
+ };
+ const read = async () => {
+ if (typeof document !== "undefined" && document.visibilityState === "hidden") return next(REFRESH_MS);
try {
const r = await fetch(`/api/points${me ? `?wallet=${me}` : ""}`, { cache: "no-store" });
- if (!r.ok) return;
+ if (!r.ok) throw new Error(String(r.status));
const v = { ...((await r.json()) as Omit, "at">), at: Date.now() };
- if (alive) setState({ wallet: me, v }); // checked after the body is read, not before
+ if (!alive) return; // checked after the body is read, not before
+ setState({ wallet: me, v });
+ failures = 0;
+ next(REFRESH_MS);
} catch {
- /* points are a side panel: say nothing on a blip */
+ /* points are a side panel: say nothing on a blip, try again soon */
+ next(RETRY_MS[Math.min(failures++, RETRY_MS.length - 1)]);
}
- }, 0);
+ };
+ id = setTimeout(() => void read(), 0);
return () => {
alive = false;
clearTimeout(id);
diff --git a/app/src/lib/launchpad/queries.ts b/app/src/lib/launchpad/queries.ts
index 7afb7d6b..3b189d90 100644
--- a/app/src/lib/launchpad/queries.ts
+++ b/app/src/lib/launchpad/queries.ts
@@ -190,10 +190,12 @@ async function withStocks(): Promise {
}
}
+/** A quote's USD price (ETH-quoted through the ETH price; null when unknown). */
function quoteUsd(q: Quote, ethUsd: number | null): number | null {
return quoteUsdOf(q, ethUsd);
}
+/** A launch row as the app uses it: indexer-only bigint columns dropped (JSON-safe), price, FDV and USD figures worked out. */
function shape(raw: Raw & { last_swap_block?: bigint; last_swap_log?: number; log_index?: number; holders_synced_block?: bigint | null }, ethUsd: number | null): LaunchRow {
// drop indexer-only bigint columns so the row is JSON-safe
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -386,6 +388,7 @@ export async function listLaunches(opts: ListOpts = {}): Promise {
return (await listLaunchesPage(opts)).items;
}
+/** One launch by chain and token, or null. */
export async function getLaunch(chain: ChainKey, token: string, ethUsd: number | null = null): Promise {
const db = maybeDb();
if (!db) return null;
@@ -420,6 +423,7 @@ export async function findLaunchChain(token: string): Promise {
export type SwapRow = { tx_hash: string; log_index: number; token: string; trader: string | null; amount0: string; amount1: string; is_buy: boolean; block_time: string; price_quote: number };
+/** A token's newest swaps, newest first, with amounts in display units. */
export async function getSwaps(chain: ChainKey, token: string, quoteDecimals: number, limit = 50): Promise {
const db = maybeDb();
if (!db) return [];
@@ -457,6 +461,7 @@ async function pictureCopies(db: NonNullable>, launch
}
}
+/** The newest launches and trades across every chain, merged newest first, for the home feed. */
export async function getLaunchFeed(limit = 24, ethUsd: number | null = null): Promise {
const db = maybeDb();
if (!db) return [];
@@ -522,6 +527,7 @@ export type LaunchTotals = {
by_chain: Record;
};
+/** Platform totals (launches, trades, USD volume, fees burned and paid to creators, GITLAWB burned); zeros without a database. */
export async function getLaunchTotals(ethUsd: number | null = null): Promise {
const empty = (): LaunchTotals => ({
launches: 0,
@@ -587,6 +593,7 @@ export const getLaunchTotalsForRequest = cache(getLaunchTotals);
export type FeeEventRow = { tx_hash: string; kind: string; currency: string | null; account: string | null; amount: string | null; quote_amount: string | null; token_amount: string | null; block_time: string };
+/** A token's newest fee collections and burns. */
export async function getFeeEvents(chain: ChainKey, token: string, limit = 30): Promise {
const db = maybeDb();
if (!db) return [];
@@ -597,6 +604,7 @@ export async function getFeeEvents(chain: ChainKey, token: string, limit = 30):
export type SyncCursor = { chain: ChainKey; cursor_block: number; head_block: number | null; last_run_at: string | null; last_error: string | null };
+/** How far the indexer has read on each chain. */
export async function launchSyncCursors(): Promise {
const db = maybeDb();
if (!db) return [];
@@ -701,6 +709,7 @@ export async function getWalletTokens(wallet: string, ethUsd: number | null = nu
export type WalletTrade = { chain: ChainKey; token: string; symbol: string; name: string; tx_hash: string; log_index: number; is_buy: boolean; quote_raw: string; quote_symbol: string; quote_decimals: number; usd: number | null; tokens: string; block_time: string };
+/** One wallet's newest trades across every chain, keyed by chain, transaction and log index. */
export async function getWalletTrades(wallet: string, ethUsd: number | null = null, limit = 50): Promise {
const db = maybeDb();
if (!db) return [];
diff --git a/app/src/lib/points/auth.ts b/app/src/lib/points/auth.ts
index 4595bdc2..76450aa7 100644
--- a/app/src/lib/points/auth.ts
+++ b/app/src/lib/points/auth.ts
@@ -1,10 +1,12 @@
/** Signed admin messages for season points (pure; node --test loads this directly). */
export const POINTS_ADMIN_ACTIONS = ["preview", "start", "publish", "unpublish", "end", "recompute"] as const;
export type PointsAdminAction = (typeof POINTS_ADMIN_ACTIONS)[number];
+/** One of the admin actions on the points season. */
export function isPointsAdminAction(v: unknown): v is PointsAdminAction {
return typeof v === "string" && (POINTS_ADMIN_ACTIONS as readonly string[]).includes(v);
}
+/** The message an admin wallet signs for one points action. */
export function buildPointsAdminMessage(p: { action: PointsAdminAction; wallet: string; nonce: string; ts: number }): string {
return [`openlaunch.lol points`, ``, `Action: ${p.action}`, `Admin: ${p.wallet.toLowerCase()}`, `Nonce: ${p.nonce}`, `Issued: ${new Date(p.ts).toISOString()}`].join("\n");
}
diff --git a/app/src/lib/points/score.ts b/app/src/lib/points/score.ts
index 4bb86372..b7b92d41 100644
--- a/app/src/lib/points/score.ts
+++ b/app/src/lib/points/score.ts
@@ -103,9 +103,12 @@ export type ScoutBreakdown = { early: number; holds: number; feesUsd: number };
export type WalletScore = { wallet: string; creator: number; scout: number; total: number; creatorWhy: CreatorBreakdown; scoutWhy: ScoutBreakdown };
export type ScoreResult = { wallets: Map; realHolders: Map };
+/** A raw token amount in whole units. */
const units = (raw: bigint, decimals: number) => Number(raw) / 10 ** decimals;
+/** The UTC day number of a moment (for per-day caps and the best-3-per-launch-day rule). */
const utcDay = (ms: number) => Math.floor(ms / DAY);
+/** Score one season from the chain index (pure): real buyers and holders, creator and scout points, caps, and the why behind each. */
export function scoreSeason(input: ScoreInput): ScoreResult {
const end = Math.min(input.now, input.seasonEnd);
// only priced tokens take part: an unpriced quote has no dollar floor to hold anything to
@@ -387,6 +390,7 @@ export function isEligible(p: { x_status: string; x_account_created: string | nu
/** Why a wallet is not on the board yet (for /me): the one step that would change it. */
export type NotEligibleReason = "no_profile" | "not_verified" | "account_too_new" | "few_followers" | "hidden" | "kept_off";
+/** Why a wallet is not on the board yet, as the one step that would change it (null when it is eligible). */
export function notEligibleReason(p: { x_status: string; x_account_created: string | null; x_followers: number | null; points_flag: string | null; hidden: boolean; deleted_at?: string | null } | null, now: number): NotEligibleReason | null {
if (!p || p.deleted_at) return "no_profile";
if (p.points_flag) return "kept_off";
diff --git a/app/src/lib/points/server.ts b/app/src/lib/points/server.ts
index 08f68b65..558b6ffb 100644
--- a/app/src/lib/points/server.ts
+++ b/app/src/lib/points/server.ts
@@ -39,6 +39,7 @@ export async function publicSeason(): Promise {
return s ?? null;
}
+/** Whether a season's end has passed. */
export function seasonEnded(s: Pick, now = Date.now()): boolean {
return new Date(s.ends_at).getTime() <= now;
}
@@ -193,8 +194,14 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis
breakdown: { creator: s.creatorWhy, scout: s.scoutWhy },
}));
- await write.begin(async (tx) => {
+ const wrote = await write.begin(async (tx) => {
const t = tx as unknown as Db;
+ // the season as it is now, locked: a publish (which restarts the clock and drops the shadow rows), an end or a
+ // restart that landed while this run was reading means its rows describe a window that no longer exists
+ const [cur] = await t<{ starts_at: Date | string; ends_at: Date | string; published_at: Date | string | null }[]>`
+ SELECT starts_at, ends_at, published_at FROM bb_seasons WHERE id = ${season.id} FOR UPDATE`;
+ const same = (a: Date | string | null | undefined, b: Date | string | null | undefined) => (a == null || b == null ? a == b : new Date(a).getTime() === new Date(b).getTime());
+ if (!cur || !same(cur.starts_at, season.starts_at) || !same(cur.ends_at, season.ends_at) || !same(cur.published_at, season.published_at)) return false;
await t`DELETE FROM bb_points WHERE season_id = ${season.id}`;
for (let i = 0; i < rows.length; i += 1000) {
const part = rows.slice(i, i + 1000);
@@ -207,7 +214,9 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis
AS u(w, c, s, tot, e, rc, rs, b)`; // booleans and nullable ints travel as text: the driver does not type those arrays
}
await t`UPDATE bb_seasons SET computed_at = now(), computed_until = ${until} WHERE id = ${season.id}`;
+ return true;
});
+ if (!wrote) return { skipped: "the season changed while it was being computed (the next run uses the new window)" };
if (dropped) console.log(`[points] ${season.slug}: ${dropped} token(s) without a price left out of this run`);
return { wallets: rows.length, eligible: rows.filter((r) => r.eligible).length, ms: Date.now() - t0 };
}
@@ -267,6 +276,7 @@ export async function recomputeNow(): Promise }> {
const db = maybeDb();
if (!db) return { rows: [], names: {} };
@@ -279,6 +289,7 @@ export async function boardRows(seasonId: number, board: Board, limit = 100): Pr
return { rows, names: await namesFor(rows.map((r) => r.wallet)) };
}
+/** One wallet's points and ranks in a season, or null. */
export async function walletPoints(seasonId: number, wallet: string): Promise {
const db = maybeDb();
if (!db || !isAddress(wallet)) return null;
@@ -312,7 +323,12 @@ export async function startSeason(days = 28): Promise {
* points are dropped (they were for tuning). Any later publish (after hiding the boards, or of an ended season) only
* shows the boards again: the clock and the points stay as they are.
*/
-export async function publishSeason(id: number): Promise<"started" | "shown"> {
+/**
+ * Make a season public. The first publish starts its clock now and drops the shadow run's rows ("started"); showing a
+ * season again after a hide changes neither ("shown"). A season that never went public and has ended is "refused":
+ * its shadow boards were never told to anyone, and they never become public.
+ */
+export async function publishSeason(id: number): Promise<"started" | "shown" | "refused"> {
const db = maybeDb()!;
return db.begin(async (tx) => {
const t = tx as unknown as Db;
@@ -323,16 +339,18 @@ export async function publishSeason(id: number): Promise<"started" | "shown"> {
await t`DELETE FROM bb_points WHERE season_id = ${id}`;
return "started" as const;
}
- await t`UPDATE bb_seasons SET public = true WHERE id = ${id}`;
- return "shown" as const;
- }) as Promise<"started" | "shown">;
+ const shown = await t`UPDATE bb_seasons SET public = true WHERE id = ${id} AND published_at IS NOT NULL RETURNING id`;
+ return shown.length ? ("shown" as const) : ("refused" as const);
+ }) as Promise<"started" | "shown" | "refused">;
}
+/** Take a season's boards out of public view (its clock and points stay). */
export async function hideSeason(id: number): Promise {
const db = maybeDb()!;
await db`UPDATE bb_seasons SET public = false WHERE id = ${id}`;
}
+/** End a season now (or keep an earlier end): the final compute follows. */
export async function endSeasonNow(id: number): Promise {
const db = maybeDb()!;
await db`UPDATE bb_seasons SET ends_at = LEAST(ends_at, now()) WHERE id = ${id}`;
From 4c19994f7aef01a4885b5ca6e3960b629e8e3f7c Mon Sep 17 00:00:00 2001
From: Kevin Codex
Date: Fri, 9 Oct 2026 14:06:18 +0800
Subject: [PATCH 09/13] fix(points): CodeRabbit full review on #85 (signed
season length, final vs ended, discarded runs, admin refresh, neutral
standing)
- The start signature names the season's length: one normalizer
(normalizeSeasonDays) builds the same "Season length: N days" line on the
client and the server, so a signed request cannot be replayed with a
different length. Other actions carry no length line.
- Final standings are "final" only once a compute covered the season's end,
not when the clock passes it (a missing price can delay that compute).
/api/points returns `final` next to `ended`; the leaderboard, the season
card and your standing say "Season over, final standings within the hour"
until then.
- Starting a season needs the previous one's final standings only if it went
public: a discarded hidden run has none to keep and never blocks the next
season, even while a price is missing.
- Admin panel: an action that went through is reported as done even when
the boards' refresh fails or its signature is declined; the boards from
before the action are cleared, so no stale control is offered as current.
- Your standing offers "your points are waiting" only for a step the wallet
can take; a reason the API keeps back (a moderator's call) gets a neutral
"Not on the board".
---
app/src/app/api/points/admin/route.ts | 10 ++++---
app/src/app/api/points/route.ts | 7 ++---
app/src/app/leaderboard/page.tsx | 7 ++---
app/src/components/points/PointsAdmin.tsx | 31 +++++++++++++++++++---
app/src/components/points/SeasonCard.tsx | 2 +-
app/src/components/points/YourStanding.tsx | 5 +++-
app/src/components/points/usePoints.ts | 2 +-
app/src/lib/points/auth.test.ts | 27 +++++++++++++++++++
app/src/lib/points/auth.ts | 22 ++++++++++++---
app/src/lib/points/server.ts | 6 ++++-
10 files changed, 98 insertions(+), 21 deletions(-)
create mode 100644 app/src/lib/points/auth.test.ts
diff --git a/app/src/app/api/points/admin/route.ts b/app/src/app/api/points/admin/route.ts
index 81a7d4b9..81cd3f16 100644
--- a/app/src/app/api/points/admin/route.ts
+++ b/app/src/app/api/points/admin/route.ts
@@ -2,7 +2,7 @@ import { NextResponse } from "next/server";
import { rateLimited } from "@/lib/launchpad/editServer";
import { clientIp, readJson } from "@/lib/profiles/http";
import { admitAdmin } from "@/lib/profiles/server";
-import { buildPointsAdminMessage, isPointsAdminAction } from "@/lib/points/auth";
+import { buildPointsAdminMessage, isPointsAdminAction, normalizeSeasonDays } from "@/lib/points/auth";
import { currentSeason, endSeasonNow, finalizeIfEnded, hideSeason, previewBoards, publishSeason, recomputeNow, seasonEnded, startSeason } from "@/lib/points/server";
export const dynamic = "force-dynamic";
@@ -18,15 +18,17 @@ export async function POST(req: Request) {
if (!b) return NextResponse.json({ error: "bad json" }, { status: 400 });
const action = b.action;
if (!isPointsAdminAction(action)) return NextResponse.json({ error: "bad action" }, { status: 400 });
- const a = await admitAdmin({ chain: b.chain, wallet: b.wallet, nonce: b.nonce, ts: b.ts, signature: b.signature }, (wallet, nonce, ts) => buildPointsAdminMessage({ action, wallet, nonce, ts }));
+ const days = normalizeSeasonDays(b.days); // signed for "start": the season is exactly as long as the admin signed
+ const a = await admitAdmin({ chain: b.chain, wallet: b.wallet, nonce: b.nonce, ts: b.ts, signature: b.signature }, (wallet, nonce, ts) => buildPointsAdminMessage({ action, wallet, nonce, ts, days }));
if (!a.ok) return NextResponse.json({ error: a.error }, { status: a.status });
try {
const season = await currentSeason();
switch (action) {
case "start": {
if (season && !seasonEnded(season)) return NextResponse.json({ error: `${season.name} is still running` }, { status: 409 });
- if (season && !(await finalizeIfEnded(season))) return NextResponse.json({ error: `${season.name}'s final standings could not be computed yet; try again in a minute` }, { status: 409 });
- const days = Math.min(90, Math.max(1, Math.trunc(Number(b.days) || 28)));
+ // a season that went public keeps its final standings, so they are computed first; a hidden run that was
+ // discarded has none to keep, and never blocks the next season (not even when a price is missing)
+ if (season && season.published_at && !(await finalizeIfEnded(season))) return NextResponse.json({ error: `${season.name}'s final standings could not be computed yet; try again in a minute` }, { status: 409 });
const s = await startSeason(days);
void recomputeNow().catch(() => {});
return NextResponse.json({ ok: true, season: s });
diff --git a/app/src/app/api/points/route.ts b/app/src/app/api/points/route.ts
index cf3e3d20..45dac82b 100644
--- a/app/src/app/api/points/route.ts
+++ b/app/src/app/api/points/route.ts
@@ -1,13 +1,14 @@
import { NextResponse } from "next/server";
import { isAddress } from "viem";
import { memo } from "@/lib/launchpad/memo";
-import { publicSeason, seasonEnded, walletPoints, walletReason } from "@/lib/points/server";
+import { publicSeason, seasonEnded, seasonFinal, walletPoints, walletReason } from "@/lib/points/server";
export const dynamic = "force-dynamic";
/**
* GET /api/points[?wallet=0x…] → the public season (null while it is not public) and, with a wallet, its points and
- * what stands between it and the board (reason null = it is eligible).
+ * what stands between it and the board (reason null = it is eligible, or nothing it can do). `ended` is the clock;
+ * `final` says the final standings are in (a compute covered the end), which can come later than the clock.
*/
export async function GET(req: Request) {
const w = (new URL(req.url).searchParams.get("wallet") ?? "").toLowerCase();
@@ -17,7 +18,7 @@ export async function GET(req: Request) {
const [me, raw] = isAddress(w) ? await Promise.all([memo(`points:me:${season.id}:${w}`, 10_000, () => walletPoints(season.id, w)), memo(`points:why:${w}`, 10_000, () => walletReason(w))]) : [null, null];
// anyone can ask about any wallet: never say whether a moderator hid it or kept it off the boards
const reason = raw === "kept_off" || raw === "hidden" ? null : raw;
- return NextResponse.json({ season: { name: season.name, starts_at: season.starts_at, ends_at: season.ends_at, ended: seasonEnded(season) }, me, reason }, { headers: { "cache-control": "no-store" } });
+ return NextResponse.json({ season: { name: season.name, starts_at: season.starts_at, ends_at: season.ends_at, ended: seasonEnded(season), final: seasonFinal(season) }, me, reason }, { headers: { "cache-control": "no-store" } });
} catch (err) {
console.error("[points] read failed:", err instanceof Error ? err.message : err);
return NextResponse.json({ error: "could not load points" }, { status: 502 });
diff --git a/app/src/app/leaderboard/page.tsx b/app/src/app/leaderboard/page.tsx
index 7fd0715f..942374c4 100644
--- a/app/src/app/leaderboard/page.tsx
+++ b/app/src/app/leaderboard/page.tsx
@@ -6,7 +6,7 @@ import NamesProvider from "@/components/profile/NamesProvider";
import { WhoAvatar, WhoName } from "@/components/profile/Who";
import YourStanding from "@/components/points/YourStanding";
import { memo } from "@/lib/launchpad/memo";
-import { boardRows, publicSeason, seasonEnded, type Board } from "@/lib/points/server";
+import { boardRows, publicSeason, seasonEnded, seasonFinal, type Board } from "@/lib/points/server";
import { pageMetadata } from "@/lib/seo";
export const dynamic = "force-dynamic";
@@ -42,6 +42,7 @@ export default async function LeaderboardPage({ searchParams }: { searchParams:
const data = await memo(`points:board:${season.id}:${board}`, 30_000, () => boardRows(season.id, board));
const left = daysLeft(season.ends_at);
const ended = seasonEnded(season);
+ const final = seasonFinal(season); // the final compute ran: until then the board may still move
const tab = (b: Board, label: string) => (
{label}
);
@@ -60,8 +61,8 @@ export default async function LeaderboardPage({ searchParams }: { searchParams:
ended {new Date(season.ends_at).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })}{final ? "" : " · final standings within the hour"}
>
) : (
<>
diff --git a/app/src/components/points/PointsAdmin.tsx b/app/src/components/points/PointsAdmin.tsx
index fff05507..da576092 100644
--- a/app/src/components/points/PointsAdmin.tsx
+++ b/app/src/components/points/PointsAdmin.tsx
@@ -5,7 +5,7 @@ import { useAccount, useConfig } from "wagmi";
import { getWalletClient } from "wagmi/actions";
import { useHydratedAccount } from "@/lib/useHydratedAccount";
import { btn, card } from "@/components/ui";
-import { buildPointsAdminMessage, type PointsAdminAction } from "@/lib/points/auth";
+import { buildPointsAdminMessage, normalizeSeasonDays, type PointsAdminAction } from "@/lib/points/auth";
import type { NameEntry } from "@/lib/profiles/server";
import { CHAINS, DEFAULT_CHAIN, shortAddr, type ChainKey } from "@/lib/chainPublic";
import { nowMs } from "@/lib/launchpad/time";
@@ -15,6 +15,16 @@ import { nonce } from "@/lib/nonce";
type Row = { rank: number; wallet: string; points: number; eligible: boolean; why: string };
type Preview = { season: { name: string; starts_at: string; ends_at: string; public: boolean; published_at: string | null } | null; creator?: Row[]; scout?: Row[]; names?: Record; computed_at?: string | null; wallets?: number; eligible?: number; at: number };
+/** What an action did, said when it went through but the boards could not be loaded again after it. */
+const DONE: Record = {
+ preview: "Loaded",
+ start: "Season started (hidden)",
+ publish: "Published",
+ unpublish: "Boards hidden",
+ end: "Season ended",
+ recompute: "Recompute started",
+};
+
/**
* Season controls for an admin: start a season (not public), see the shadow boards (eligible or not, with why), publish
* when the trial week looks right, recompute now, end early. Every action is an admin signature.
@@ -43,7 +53,9 @@ export default function PointsAdmin() {
const n = nonce();
const ts = nowMs();
const wallet = await getWalletClient(config);
- const signature = await wallet.signMessage({ message: buildPointsAdminMessage({ action, wallet: address, nonce: n, ts }) });
+ // starting signs the season's length too (the server builds the same text from the same normalized number)
+ const days = normalizeSeasonDays(extra.days);
+ const signature = await wallet.signMessage({ message: buildPointsAdminMessage({ action, wallet: address, nonce: n, ts, days }) });
const res = await fetch("/api/points/admin", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action, chain, wallet: address, nonce: n, ts, signature, ...extra }) });
const d = (await res.json()) as Record & { error?: string };
if (!res.ok) throw new Error(d.error ?? "failed");
@@ -53,12 +65,23 @@ export default function PointsAdmin() {
const who = me;
setBusy(true);
setFailed(null);
+ let done = false; // the action itself went through (only the boards' refresh can still fail)
try {
- if (action !== "preview") await call(action, extra);
+ if (action !== "preview") {
+ await call(action, extra);
+ done = true;
+ }
const v = { ...((await call("preview")) as Omit), at: Date.now() };
if (current.current === who) setLoaded({ wallet: who, v });
} catch (e) {
- if (current.current === who) setFailed({ wallet: who, message: friendlyError(e) });
+ if (current.current !== who) return;
+ if (done) {
+ // the boards on screen are from before the action: they go, so no stale control is offered as current
+ setLoaded(null);
+ setFailed({ wallet: who, message: `${DONE[action]}. Loading the boards again failed (${friendlyError(e)}): press Load to see where things stand.` });
+ } else {
+ setFailed({ wallet: who, message: friendlyError(e) });
+ }
} finally {
setBusy(false);
}
diff --git a/app/src/components/points/SeasonCard.tsx b/app/src/components/points/SeasonCard.tsx
index 5ee37ca1..5141f1b3 100644
--- a/app/src/components/points/SeasonCard.tsx
+++ b/app/src/components/points/SeasonCard.tsx
@@ -35,7 +35,7 @@ export default function SeasonCard({ address }: { address: string }) {
return (
{me ? (
diff --git a/app/src/components/points/YourStanding.tsx b/app/src/components/points/YourStanding.tsx
index 75059266..9085d82a 100644
--- a/app/src/components/points/YourStanding.tsx
+++ b/app/src/components/points/YourStanding.tsx
@@ -10,6 +10,9 @@ export default function YourStanding({ board }: { board: "creator" | "scout" })
const p = usePoints(address);
if (!address || !p?.season || !p.me) return null;
const ended = p.season.ended;
+ const final = p.season.final;
+ // only a step the wallet can take is offered: a reason the API keeps back (a moderator's call) leaves it neutral
+ const actionable = p.reason === "no_profile" || p.reason === "not_verified" || p.reason === "account_too_new" || p.reason === "few_followers";
const me = p.me;
const points = board === "creator" ? me.creator : me.scout;
const rank = board === "creator" ? me.rank_creator : me.rank_scout;
@@ -18,7 +21,7 @@ export default function YourStanding({ board }: { board: "creator" | "scout" })
You
-
{me.eligible ? (rank ? <>Rank #{rank}> : "Not ranked on this board") : ended ? "Not on the final board" : <>Your points are waiting. See what unlocks them.>}{why ? · {why} : null}
+
{me.eligible ? (rank ? <>Rank #{rank}> : "Not ranked on this board") : ended ? (final ? "Not on the final board" : "Not on the board") : actionable ? <>Your points are waiting. See what unlocks them.> : "Not on the board"}{why ? · {why} : null}
{points.toLocaleString("en-US")}
diff --git a/app/src/components/points/usePoints.ts b/app/src/components/points/usePoints.ts
index f05ec389..a1e034af 100644
--- a/app/src/components/points/usePoints.ts
+++ b/app/src/components/points/usePoints.ts
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import type { WalletPoints } from "@/lib/points/server";
import type { NotEligibleReason } from "@/lib/points/score";
-export type PointsState = { season: { name: string; starts_at: string; ends_at: string; ended: boolean } | null; me: WalletPoints | null; reason: NotEligibleReason | null; at: number } | null;
+export type PointsState = { season: { name: string; starts_at: string; ends_at: string; ended: boolean; final: boolean } | null; me: WalletPoints | null; reason: NotEligibleReason | null; at: number } | null;
const REFRESH_MS = 5 * 60_000; // points are recomputed hourly; a page left open picks up a publish or a new run
const RETRY_MS = [10_000, 30_000, 60_000]; // after a failed read, then back to the normal refresh
diff --git a/app/src/lib/points/auth.test.ts b/app/src/lib/points/auth.test.ts
new file mode 100644
index 00000000..444728d6
--- /dev/null
+++ b/app/src/lib/points/auth.test.ts
@@ -0,0 +1,27 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { buildPointsAdminMessage, normalizeSeasonDays } from "./auth.ts";
+
+const base = { wallet: "0xAbC0000000000000000000000000000000000001", nonce: "a".repeat(32), ts: 1_760_000_000_000 };
+
+test("starting a season signs its length: another length is another message", () => {
+ const m28 = buildPointsAdminMessage({ ...base, action: "start", days: 28 });
+ assert.match(m28, /Season length: 28 days/);
+ assert.notEqual(buildPointsAdminMessage({ ...base, action: "start", days: 1 }), m28, "a request changed to 1 day no longer matches the signature");
+});
+
+test("the length is normalized the same way on both sides (1 to 90 whole days, 28 by default)", () => {
+ assert.equal(normalizeSeasonDays(undefined), 28);
+ assert.equal(normalizeSeasonDays("abc"), 28);
+ assert.equal(normalizeSeasonDays(0), 28);
+ assert.equal(normalizeSeasonDays(500), 90);
+ assert.equal(normalizeSeasonDays(-3), 1);
+ assert.equal(normalizeSeasonDays("14.9"), 14);
+ assert.equal(buildPointsAdminMessage({ ...base, action: "start", days: 500 }), buildPointsAdminMessage({ ...base, action: "start", days: 90 }));
+});
+
+test("other actions carry no length line", () => {
+ for (const action of ["preview", "publish", "unpublish", "end", "recompute"] as const) {
+ assert.doesNotMatch(buildPointsAdminMessage({ ...base, action, days: 5 }), /Season length/, action);
+ }
+});
diff --git a/app/src/lib/points/auth.ts b/app/src/lib/points/auth.ts
index 76450aa7..7a36b83a 100644
--- a/app/src/lib/points/auth.ts
+++ b/app/src/lib/points/auth.ts
@@ -6,7 +6,23 @@ export function isPointsAdminAction(v: unknown): v is PointsAdminAction {
return typeof v === "string" && (POINTS_ADMIN_ACTIONS as readonly string[]).includes(v);
}
-/** The message an admin wallet signs for one points action. */
-export function buildPointsAdminMessage(p: { action: PointsAdminAction; wallet: string; nonce: string; ts: number }): string {
- return [`openlaunch.lol points`, ``, `Action: ${p.action}`, `Admin: ${p.wallet.toLowerCase()}`, `Nonce: ${p.nonce}`, `Issued: ${new Date(p.ts).toISOString()}`].join("\n");
+/** A season length as the server will use it: whole days, 1 to 90, 28 when missing or not a number. */
+export function normalizeSeasonDays(raw: unknown): number {
+ return Math.min(90, Math.max(1, Math.trunc(Number(raw) || 28)));
+}
+
+/**
+ * The message an admin wallet signs for one points action. Starting a season also names its length, so a signed
+ * request cannot be replayed with a different one.
+ */
+export function buildPointsAdminMessage(p: { action: PointsAdminAction; wallet: string; nonce: string; ts: number; days?: number }): string {
+ return [
+ `openlaunch.lol points`,
+ ``,
+ `Action: ${p.action}`,
+ ...(p.action === "start" ? [`Season length: ${normalizeSeasonDays(p.days)} days`] : []),
+ `Admin: ${p.wallet.toLowerCase()}`,
+ `Nonce: ${p.nonce}`,
+ `Issued: ${new Date(p.ts).toISOString()}`,
+ ].join("\n");
}
diff --git a/app/src/lib/points/server.ts b/app/src/lib/points/server.ts
index 558b6ffb..7390fe32 100644
--- a/app/src/lib/points/server.ts
+++ b/app/src/lib/points/server.ts
@@ -45,7 +45,11 @@ export function seasonEnded(s: Pick, now = Date.now()): boole
}
/** The final standings are fixed once a compute has read the data through the season's end. */
-function isFinal(s: Season): boolean {
+/** Whether a season's final standings are in: a compute covered its end (the clock passing is not enough). */
+export function seasonFinal(s: Pick): boolean {
+ return isFinal(s);
+}
+function isFinal(s: Pick): boolean {
return Boolean(s.computed_until && new Date(s.computed_until).getTime() >= new Date(s.ends_at).getTime());
}
From 10ca2c2cadd68163e6573cb111ad4960e4fe6988 Mon Sep 17 00:00:00 2001
From: Kevin Codex
Date: Fri, 9 Oct 2026 15:42:10 +0800
Subject: [PATCH 10/13] fix(points): code review on #85 (finality, season-end
bounds, dumps via transfers, capped best 3, real buys, signed season ids,
one-signature actions)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Final standings
- A season is final only once a compute covered its end AND every indexer's cursor block is past it (computed_until is
capped at the least cursor time). The clock passing the end is no longer enough.
- Every read stops at the season's end: swaps by block_time, transfers by each chain's last block before the end (found
on block timestamps by interpolation, bracketed by indexed swaps, cached), so a late final compute sees the season as
it ended.
- A final run waits while a normally-priced quote (MUSEWORLD, a stock) has no price; an hourly run still drops only
those tokens. Unlisted quotes never count.
- One price snapshot per run (quotePricer), so a page refresh mid-run cannot change it.
Scoring
- Dump rule: any in-season outflow by the creator (a transfer to a side wallet that then sells included; burns
excepted), told from earlier ones by the season's first block per chain.
- Best 3 per launch day are ranked with the per-trader fee cap applied (one big fee payer can no longer push out a
token with real holders).
- Hold points only for a token the wallet first bought in the season with a real buy.
- A real buyer must receive the tokens of that buy in its own transaction (a relayer or bundler credited with the swap
is not a buyer).
Admin
- publish / unpublish / end sign the season id they act on; the previous public season can be hidden or shown while
the next one runs hidden.
- Every action answers with the admin view: one signature per action, not two.
- startSeason runs under an advisory lock, refuses while any season is not over, and takes its slug and number from
the row's id (concurrent starts make one season; a deleted season never collides).
Public API
- The reason is worked out as if the kept-off flag were not there (a kept-off wallet reads like any wallet in its
state); the client only keeps reasons it knows.
- Wallet lookups are rate limited per IP and kept out of the shared memo.
Indexes: bb_token_transfers (chain_id, token, to_addr, block_number) and (…, from_addr, …), built CONCURRENTLY.
Rules page updated for the dump, holds and real-buyer changes.
Tests: scorer (dump via side wallet / burn, capped best 3, holds, relayer buys), block search, signed season ids;
DB test on a throwaway copy (finality, late sell, price wait, concurrent start, admin season actions, reason, rate
limit) fails on the old code and passes on the new.
---
app/db/concurrent-indexes.sql | 4 +
app/src/app/api/points/admin/route.ts | 72 ++++--
app/src/app/api/points/route.ts | 13 +-
app/src/app/rules/page.tsx | 6 +-
app/src/components/points/PointsAdmin.tsx | 54 +++--
app/src/components/points/SeasonCard.tsx | 16 +-
app/src/components/points/YourStanding.tsx | 4 +-
app/src/components/points/usePoints.ts | 8 +-
app/src/lib/launchpad/museworld.test.ts | 4 +-
app/src/lib/launchpad/queries.ts | 25 ++-
app/src/lib/launchpad/twig.test.ts | 3 +-
app/src/lib/launchpad/unlisted-quote.test.ts | 3 +-
app/src/lib/points/auth.test.ts | 14 +-
app/src/lib/points/auth.ts | 16 +-
app/src/lib/points/blocks.test.ts | 45 ++++
app/src/lib/points/blocks.ts | 23 ++
app/src/lib/points/score.test.ts | 61 ++++-
app/src/lib/points/score.ts | 168 ++++++++------
app/src/lib/points/server.ts | 223 +++++++++++++++----
19 files changed, 568 insertions(+), 194 deletions(-)
create mode 100644 app/src/lib/points/blocks.test.ts
create mode 100644 app/src/lib/points/blocks.ts
diff --git a/app/db/concurrent-indexes.sql b/app/db/concurrent-indexes.sql
index 71a1a125..5c39f688 100644
--- a/app/db/concurrent-indexes.sql
+++ b/app/db/concurrent-indexes.sql
@@ -11,3 +11,7 @@ CREATE INDEX CONCURRENTLY IF NOT EXISTS bb_launch_swaps_unattributed_idx ON bb_l
CREATE INDEX CONCURRENTLY IF NOT EXISTS bb_launch_swaps_receipt_pending_idx ON bb_launch_swaps (chain_id, block_number DESC) WHERE trader_via = 'receipt_pending';
-- one wallet's trades (profile pages, /me, posting eligibility, points) without scanning every swap
CREATE INDEX CONCURRENTLY IF NOT EXISTS bb_launch_swaps_trader_idx ON bb_launch_swaps (trader, block_number DESC);
+-- points (2026-10-09): every balance change of a season's buyers and launchers, read by wallet in both directions, up
+-- to the season's last block; and transfers out of a launcher or a fee recipient (who got the token from them)
+CREATE INDEX CONCURRENTLY IF NOT EXISTS bb_token_transfers_to_idx ON bb_token_transfers (chain_id, token, to_addr, block_number);
+CREATE INDEX CONCURRENTLY IF NOT EXISTS bb_token_transfers_from_idx ON bb_token_transfers (chain_id, token, from_addr, block_number);
diff --git a/app/src/app/api/points/admin/route.ts b/app/src/app/api/points/admin/route.ts
index 81cd3f16..80359223 100644
--- a/app/src/app/api/points/admin/route.ts
+++ b/app/src/app/api/points/admin/route.ts
@@ -2,15 +2,32 @@ import { NextResponse } from "next/server";
import { rateLimited } from "@/lib/launchpad/editServer";
import { clientIp, readJson } from "@/lib/profiles/http";
import { admitAdmin } from "@/lib/profiles/server";
-import { buildPointsAdminMessage, isPointsAdminAction, normalizeSeasonDays } from "@/lib/points/auth";
-import { currentSeason, endSeasonNow, finalizeIfEnded, hideSeason, previewBoards, publishSeason, recomputeNow, seasonEnded, startSeason } from "@/lib/points/server";
+import { SEASON_ACTIONS, buildPointsAdminMessage, isPointsAdminAction, normalizeSeasonDays, normalizeSeasonId } from "@/lib/points/auth";
+import { currentSeason, endSeasonNow, finalizeIfEnded, hideSeason, previewBoards, previousPublishedSeason, publishSeason, recomputeNow, seasonById, seasonEnded, seasonFinal, startSeason, type Season } from "@/lib/points/server";
export const dynamic = "force-dynamic";
+/** What an admin sees of a season (and whether its final standings are in). */
+const view = (s: Season) => ({ id: s.id, name: s.name, starts_at: s.starts_at, ends_at: s.ends_at, public: s.public, published_at: s.published_at, final: seasonFinal(s) });
+
+/**
+ * The admin view: the current season with its shadow boards, and the previous season that went public (so a public
+ * Season 1 can be hidden or shown again while Season 2 runs hidden). Every action answers with it too, so an action
+ * is one signature (the boards come back with it, no second signed read).
+ */
+async function adminView() {
+ const season = await currentSeason();
+ if (!season) return { season: null, previous: null };
+ const previous = await previousPublishedSeason(season.id);
+ return { season: view(season), previous: previous ? view(previous) : null, ...(await previewBoards(season.id)) };
+}
+
/**
- * POST {action, days?, chain, wallet, nonce, ts, signature} → one admin-signed points action:
- * preview (the shadow boards), start (a new season as a shadow run; the previous one's final standings are computed
- * first), publish (the season starts now for everyone) / unpublish, end (now), recompute (now; not after the final).
+ * POST {action, days?, seasonId?, chain, wallet, nonce, ts, signature} → one admin-signed points action:
+ * preview (the admin view), start (a new season as a shadow run; a previous season that went public gets its final
+ * standings first), publish / unpublish (show or hide one named season; the first publish of the current one starts
+ * its clock), end (the named current season, now), recompute (now; not after the final). The length of a start and
+ * the season of publish / unpublish / end are part of the signed message.
*/
export async function POST(req: Request) {
if (rateLimited(`points-admin:ip:${clientIp(req)}`, 30)) return NextResponse.json({ error: "slow down" }, { status: 429 });
@@ -19,46 +36,53 @@ export async function POST(req: Request) {
const action = b.action;
if (!isPointsAdminAction(action)) return NextResponse.json({ error: "bad action" }, { status: 400 });
const days = normalizeSeasonDays(b.days); // signed for "start": the season is exactly as long as the admin signed
- const a = await admitAdmin({ chain: b.chain, wallet: b.wallet, nonce: b.nonce, ts: b.ts, signature: b.signature }, (wallet, nonce, ts) => buildPointsAdminMessage({ action, wallet, nonce, ts, days }));
+ const seasonId = normalizeSeasonId(b.seasonId); // signed for publish / unpublish / end: the season they act on
+ if (SEASON_ACTIONS.includes(action) && !seasonId) return NextResponse.json({ error: "which season? reload the admin page" }, { status: 400 });
+ const a = await admitAdmin({ chain: b.chain, wallet: b.wallet, nonce: b.nonce, ts: b.ts, signature: b.signature }, (wallet, nonce, ts) => buildPointsAdminMessage({ action, wallet, nonce, ts, days, seasonId }));
if (!a.ok) return NextResponse.json({ error: a.error }, { status: a.status });
+ const done = async (extra: Record = {}) => NextResponse.json({ ok: true, ...extra, view: await adminView() }, { headers: { "cache-control": "no-store" } });
try {
const season = await currentSeason();
switch (action) {
+ case "preview":
+ return NextResponse.json(await adminView(), { headers: { "cache-control": "no-store" } });
case "start": {
if (season && !seasonEnded(season)) return NextResponse.json({ error: `${season.name} is still running` }, { status: 409 });
// a season that went public keeps its final standings, so they are computed first; a hidden run that was
// discarded has none to keep, and never blocks the next season (not even when a price is missing)
if (season && season.published_at && !(await finalizeIfEnded(season))) return NextResponse.json({ error: `${season.name}'s final standings could not be computed yet; try again in a minute` }, { status: 409 });
const s = await startSeason(days);
+ if (!s) return NextResponse.json({ error: "a season is already running" }, { status: 409 });
void recomputeNow().catch(() => {});
- return NextResponse.json({ ok: true, season: s });
+ return done({ season: s });
}
case "publish": {
- if (!season) return NextResponse.json({ error: "no season" }, { status: 404 });
- // a shadow run that ended without ever going public has no standings anyone was told about
- if (!season.published_at && seasonEnded(season)) return NextResponse.json({ error: `${season.name} ended without being published; start a new season` }, { status: 409 });
- const r = await publishSeason(season.id);
+ const s = await seasonById(seasonId);
+ if (!s) return NextResponse.json({ error: "no such season" }, { status: 404 });
+ // a season's first publish starts it for everyone: only the current one, and only while it runs
+ if (!s.published_at && (s.id !== season?.id || seasonEnded(s))) return NextResponse.json({ error: `${s.name} ended without being published; start a new season` }, { status: 409 });
+ const r = await publishSeason(s.id);
// it ended in the moment between the check above and the publish
- if (r === "refused") return NextResponse.json({ error: `${season.name} ended without being published; start a new season` }, { status: 409 });
+ if (r === "refused") return NextResponse.json({ error: `${s.name} ended without being published; start a new season` }, { status: 409 });
if (r === "started") void recomputeNow().catch(() => {});
- return NextResponse.json({ ok: true, result: r });
+ return done({ result: r });
+ }
+ case "unpublish": {
+ const s = await seasonById(seasonId);
+ if (!s) return NextResponse.json({ error: "no such season" }, { status: 404 });
+ await hideSeason(s.id);
+ return done();
}
- case "unpublish":
- if (!season) return NextResponse.json({ error: "no season" }, { status: 404 });
- await hideSeason(season.id);
- return NextResponse.json({ ok: true });
- case "end":
- if (!season) return NextResponse.json({ error: "no season" }, { status: 404 });
+ case "end": {
+ if (!season || season.id !== seasonId || seasonEnded(season)) return NextResponse.json({ error: "only the running season can be ended; reload the admin page" }, { status: 409 });
await endSeasonNow(season.id);
- return NextResponse.json({ ok: true });
+ return done();
+ }
case "recompute": {
const r = await recomputeNow();
if (r && "error" in r) return NextResponse.json({ error: r.error }, { status: 409 });
- return NextResponse.json({ ok: true, result: r });
+ return done({ result: r });
}
- case "preview":
- if (!season) return NextResponse.json({ season: null });
- return NextResponse.json({ season, ...(await previewBoards(season.id)) }, { headers: { "cache-control": "no-store" } });
}
} catch (err) {
console.error("[points] admin failed:", err instanceof Error ? err.message : err);
diff --git a/app/src/app/api/points/route.ts b/app/src/app/api/points/route.ts
index 45dac82b..413318da 100644
--- a/app/src/app/api/points/route.ts
+++ b/app/src/app/api/points/route.ts
@@ -1,6 +1,8 @@
import { NextResponse } from "next/server";
import { isAddress } from "viem";
+import { rateLimited } from "@/lib/launchpad/editServer";
import { memo } from "@/lib/launchpad/memo";
+import { clientIp } from "@/lib/profiles/http";
import { publicSeason, seasonEnded, seasonFinal, walletPoints, walletReason } from "@/lib/points/server";
export const dynamic = "force-dynamic";
@@ -8,16 +10,19 @@ export const dynamic = "force-dynamic";
/**
* GET /api/points[?wallet=0x…] → the public season (null while it is not public) and, with a wallet, its points and
* what stands between it and the board (reason null = it is eligible, or nothing it can do). `ended` is the clock;
- * `final` says the final standings are in (a compute covered the end), which can come later than the clock.
+ * `final` says the final standings are in (a compute covered the end), which can come later than the clock. A wallet
+ * lookup is two indexed reads, rate limited per IP and kept out of the shared memo, so asking about many addresses
+ * can neither evict the hot entries nor run up the database.
*/
export async function GET(req: Request) {
const w = (new URL(req.url).searchParams.get("wallet") ?? "").toLowerCase();
+ if (w && rateLimited(`points:ip:${clientIp(req)}`, 120)) return NextResponse.json({ error: "slow down" }, { status: 429 });
try {
const season = await memo("points:public-season", 10_000, publicSeason);
if (!season) return NextResponse.json({ season: null, me: null, reason: null }, { headers: { "cache-control": "no-store" } });
- const [me, raw] = isAddress(w) ? await Promise.all([memo(`points:me:${season.id}:${w}`, 10_000, () => walletPoints(season.id, w)), memo(`points:why:${w}`, 10_000, () => walletReason(w))]) : [null, null];
- // anyone can ask about any wallet: never say whether a moderator hid it or kept it off the boards
- const reason = raw === "kept_off" || raw === "hidden" ? null : raw;
+ // anyone can ask about any wallet: the reason never says whether a moderator kept it off the boards (walletReason
+ // works it out as if that flag were not there); a hidden profile is public already
+ const [me, reason] = isAddress(w) ? await Promise.all([walletPoints(season.id, w), walletReason(w)]) : [null, null];
return NextResponse.json({ season: { name: season.name, starts_at: season.starts_at, ends_at: season.ends_at, ended: seasonEnded(season), final: seasonFinal(season) }, me, reason }, { headers: { "cache-control": "no-store" } });
} catch (err) {
console.error("[points] read failed:", err instanceof Error ? err.message : err);
diff --git a/app/src/app/rules/page.tsx b/app/src/app/rules/page.tsx
index 85252fc8..29b9cc7e 100644
--- a/app/src/app/rules/page.tsx
+++ b/app/src/app/rules/page.tsx
@@ -118,9 +118,9 @@ export default function RulesPage() {
Season points
Points for real buyers. Nothing for noise.
See the leaderboard
Points measure reputation on openlaunch. They have no cash value, are not a token and not a promise of one, cannot be transferred or sold, and the rules can change between seasons. Wallets that game the system lose their points. Everything is computed from the chain every hour; the rules below are the whole of it.
- What counts as a real buyer?
A wallet whose first buy of a token was ${RULES.minTradeUsd} or more, made after the launch block, the next {RULES.sniperBlocks} blocks and the first {RULES.sniperMs / 1000} seconds. Never the launcher, a fee recipient, a wallet that got the token by transfer from either, or a wallet kept off points. A real holder is a real buyer with ${RULES.holderMinUsd} or more (${RULES.verifiedHolderMinUsd} for a qualifying profile, below) of that first buy still in the wallet.
Holding is judged per buy: whenever tokens leave a wallet (sold, or moved to any other wallet), the most recently received ones go first. So selling a later buy does not touch an earlier one, tokens sent in from another wallet never stand in for a buy, and moving a position out counts the same as selling it. Only tokens priced in a quote we can price count.
- How do creators earn?
On any token you launched, only what happens during the season counts. Each real holder who first bought this season and still holds a day later earns you {RULES.verifiedHolder} points if their profile qualifies (below), or {RULES.holder} if not; a week later, half again. Each holder counts once for you, on whichever of your tokens they are worth most.
Fees on buys by traders with a qualifying profile earn {RULES.creatorFeePointsPerUsd} points per $1 for each buy that is still at least half held (a buy sold straight back earns nothing), up to {RULES.creatorFeeCapPerTraderDay} from one trader a day. Of the tokens you launch on one day, only your best {RULES.bestTokensPerDay} count. A token scores nothing if you sold during the season and less than half of your own buys of it is left.
- How do traders earn?
Be one of the first {RULES.earlyFirstBuyers} real buyers of a token that now has {RULES.earlyHoldersTarget} real holders, with your first buy this season: {RULES.early} points, and {RULES.earlyStillHolding} more while you still hold half of that first buy.
Every token you first buy this season and still half-hold a day later (that buy itself, not a later one): {RULES.hold} points, up to {RULES.holdTokensPerDay} tokens a day. Fees you pay buying other people's tokens with {RULES.scoutFeeMinHolders}+ real holders ({RULES.scoutFeeMinEligibleHolders}+ of them qualifying), for each buy still at least half held: {RULES.scoutFeePointsPerUsd} points per $1, up to {RULES.scoutFeeCapPerDay} a day. Trades under ${RULES.minTradeUsd} earn nothing.
+ What counts as a real buyer?
A wallet whose first buy of a token was ${RULES.minTradeUsd} or more, made after the launch block, the next {RULES.sniperBlocks} blocks and the first {RULES.sniperMs / 1000} seconds. The wallet must receive the tokens of that buy itself (a relayer or router that only passed them on is not a buyer). Never the launcher, a fee recipient, a wallet that got the token by transfer from either, or a wallet kept off points. A real holder is a real buyer with ${RULES.holderMinUsd} or more (${RULES.verifiedHolderMinUsd} for a qualifying profile, below) of that first buy still in the wallet.
Holding is judged per buy: whenever tokens leave a wallet (sold, or moved to any other wallet), the most recently received ones go first. So selling a later buy does not touch an earlier one, tokens sent in from another wallet never stand in for a buy, and moving a position out counts the same as selling it. Only tokens priced in a quote we can price count.
+ How do creators earn?
On any token you launched, only what happens during the season counts. Each real holder who first bought this season and still holds a day later earns you {RULES.verifiedHolder} points if their profile qualifies (below), or {RULES.holder} if not; a week later, half again. Each holder counts once for you, on whichever of your tokens they are worth most.
Fees on buys by traders with a qualifying profile earn {RULES.creatorFeePointsPerUsd} points per $1 for each buy that is still at least half held (a buy sold straight back earns nothing), up to {RULES.creatorFeeCapPerTraderDay} from one trader a day. Of the tokens you launch on one day, only your best {RULES.bestTokensPerDay} count. A token scores nothing if you sold or moved tokens out during the season (to any wallet, including one of your own; burning them is fine) and less than half of your own buys of it is left.
+ How do traders earn?
Be one of the first {RULES.earlyFirstBuyers} real buyers of a token that now has {RULES.earlyHoldersTarget} real holders, with your first buy this season: {RULES.early} points, and {RULES.earlyStillHolding} more while you still hold half of that first buy.
Every token you first buy this season, with a real first buy, and still half-hold a day later (that buy itself, not a later one; a token you already held before the season does not count): {RULES.hold} points, up to {RULES.holdTokensPerDay} tokens a day. Fees you pay buying other people's tokens with {RULES.scoutFeeMinHolders}+ real holders ({RULES.scoutFeeMinEligibleHolders}+ of them qualifying), for each buy still at least half held: {RULES.scoutFeePointsPerUsd} points per $1, up to {RULES.scoutFeeCapPerDay} a day. Trades under ${RULES.minTradeUsd} earn nothing.
Who appears on the leaderboard?
Profiles verified with one post on X from an account at least {RULES.eligibleMinAgeDays} days old with {RULES.eligibleMinFollowers} or more followers. Everyone else can see the points waiting for them on their dashboard; while the season runs, they unlock for the whole season so far within the hour after the profile qualifies. The post itself earns nothing.
diff --git a/app/src/components/points/PointsAdmin.tsx b/app/src/components/points/PointsAdmin.tsx
index da576092..c26e425b 100644
--- a/app/src/components/points/PointsAdmin.tsx
+++ b/app/src/components/points/PointsAdmin.tsx
@@ -5,7 +5,7 @@ import { useAccount, useConfig } from "wagmi";
import { getWalletClient } from "wagmi/actions";
import { useHydratedAccount } from "@/lib/useHydratedAccount";
import { btn, card } from "@/components/ui";
-import { buildPointsAdminMessage, normalizeSeasonDays, type PointsAdminAction } from "@/lib/points/auth";
+import { buildPointsAdminMessage, normalizeSeasonDays, normalizeSeasonId, type PointsAdminAction } from "@/lib/points/auth";
import type { NameEntry } from "@/lib/profiles/server";
import { CHAINS, DEFAULT_CHAIN, shortAddr, type ChainKey } from "@/lib/chainPublic";
import { nowMs } from "@/lib/launchpad/time";
@@ -13,9 +13,10 @@ import { friendlyError } from "@/lib/errors";
import { nonce } from "@/lib/nonce";
type Row = { rank: number; wallet: string; points: number; eligible: boolean; why: string };
-type Preview = { season: { name: string; starts_at: string; ends_at: string; public: boolean; published_at: string | null } | null; creator?: Row[]; scout?: Row[]; names?: Record; computed_at?: string | null; wallets?: number; eligible?: number; at: number };
+type SeasonView = { id: number; name: string; starts_at: string; ends_at: string; public: boolean; published_at: string | null; final: boolean };
+type Preview = { season: SeasonView | null; previous?: SeasonView | null; creator?: Row[]; scout?: Row[]; names?: Record; computed_at?: string | null; wallets?: number; eligible?: number; at: number };
-/** What an action did, said when it went through but the boards could not be loaded again after it. */
+/** What an action did, said when it went through but its answer carried no boards (an older server, a blip). */
const DONE: Record = {
preview: "Loaded",
start: "Season started (hidden)",
@@ -27,7 +28,8 @@ const DONE: Record = {
/**
* Season controls for an admin: start a season (not public), see the shadow boards (eligible or not, with why), publish
- * when the trial week looks right, recompute now, end early. Every action is an admin signature.
+ * when the trial week looks right, recompute now, end early; and show or hide the previous public season while the
+ * next one runs hidden. Every action is one admin signature (its answer carries the boards).
*/
export default function PointsAdmin() {
const { address } = useHydratedAccount();
@@ -53,9 +55,11 @@ export default function PointsAdmin() {
const n = nonce();
const ts = nowMs();
const wallet = await getWalletClient(config);
- // starting signs the season's length too (the server builds the same text from the same normalized number)
+ // starting signs the season's length, and publish / hide / end the season they act on (the server builds the same
+ // text from the same normalized numbers)
const days = normalizeSeasonDays(extra.days);
- const signature = await wallet.signMessage({ message: buildPointsAdminMessage({ action, wallet: address, nonce: n, ts, days }) });
+ const seasonId = normalizeSeasonId(extra.seasonId);
+ const signature = await wallet.signMessage({ message: buildPointsAdminMessage({ action, wallet: address, nonce: n, ts, days, seasonId }) });
const res = await fetch("/api/points/admin", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action, chain, wallet: address, nonce: n, ts, signature, ...extra }) });
const d = (await res.json()) as Record & { error?: string };
if (!res.ok) throw new Error(d.error ?? "failed");
@@ -65,23 +69,19 @@ export default function PointsAdmin() {
const who = me;
setBusy(true);
setFailed(null);
- let done = false; // the action itself went through (only the boards' refresh can still fail)
try {
- if (action !== "preview") {
- await call(action, extra);
- done = true;
- }
- const v = { ...((await call("preview")) as Omit), at: Date.now() };
- if (current.current === who) setLoaded({ wallet: who, v });
- } catch (e) {
+ // one signature: an action answers with the admin view as it is after it (preview is that view alone)
+ const d = (await call(action, extra)) as (Record & { view?: Omit }) | null;
+ const v = action === "preview" ? (d as Omit | null) : (d?.view ?? null);
if (current.current !== who) return;
- if (done) {
- // the boards on screen are from before the action: they go, so no stale control is offered as current
+ if (v) setLoaded({ wallet: who, v: { ...v, at: Date.now() } });
+ else if (action !== "preview") {
+ // the action went through but its answer carried no boards: the ones on screen are from before it, so they go
setLoaded(null);
- setFailed({ wallet: who, message: `${DONE[action]}. Loading the boards again failed (${friendlyError(e)}): press Load to see where things stand.` });
- } else {
- setFailed({ wallet: who, message: friendlyError(e) });
+ setFailed({ wallet: who, message: `${DONE[action]}. Press Load to see where things stand.` });
}
+ } catch (e) {
+ if (current.current === who) setFailed({ wallet: who, message: friendlyError(e) });
} finally {
setBusy(false);
}
@@ -132,11 +132,21 @@ export default function PointsAdmin() {
)}
+ ) : null}
{running && confirmEnd && s ? (
// ending is for good: say what it means before the signature, above all for a run that never went public
@@ -147,7 +157,7 @@ export default function PointsAdmin() {
-
+
) : null}
diff --git a/app/src/components/points/SeasonCard.tsx b/app/src/components/points/SeasonCard.tsx
index 5141f1b3..a0802a43 100644
--- a/app/src/components/points/SeasonCard.tsx
+++ b/app/src/components/points/SeasonCard.tsx
@@ -2,17 +2,16 @@
import Link from "next/link";
import { ArrowRight, Trophy } from "lucide-react";
-import { RULES, type NotEligibleReason } from "@/lib/points/score";
+import { RULES, type PublicReason } from "@/lib/points/score";
import { usePoints } from "./usePoints";
-/** The one step that puts a wallet on the board, by what stands in the way. */
-const NEXT_STEP: Record = {
+/** The one step that puts a wallet on the board, by what stands in the way (a hidden profile has none to take). */
+const NEXT_STEP: Record = {
no_profile: "Create your profile and verify it with one post on X",
not_verified: "Verify your profile with one post on X",
account_too_new: `Your X account needs to be at least ${RULES.eligibleMinAgeDays} days old`,
few_followers: `Your X account needs at least ${RULES.eligibleMinFollowers} followers`,
hidden: "Your profile is hidden by moderators",
- kept_off: "This wallet is kept off the boards",
};
/**
@@ -31,7 +30,8 @@ export default function SeasonCard({ address }: { address: string }) {
{why ?
{why}
: null}
);
- const waiting = me && !me.eligible && p.reason && p.reason !== "kept_off" && p.reason !== "hidden" && !p.season.ended;
+ const step = p.reason && p.reason !== "hidden" ? p.reason : null; // a step the wallet can take itself
+ const waiting = me && !me.eligible && step && !p.season.ended;
return (
- {me.total.toLocaleString("en-US")} points are waiting for you. {NEXT_STEP[p.reason as NotEligibleReason]}, and they unlock for the whole season so far within the hour.
+ {me.total.toLocaleString("en-US")} points are waiting for you. {NEXT_STEP[step]}, and they unlock for the whole season so far within the hour.
{p.season.ended ? "No points this season." : <>No points yet this season. Launch something people hold, or find tokens early and hold them.{p.reason && p.reason !== "kept_off" && p.reason !== "hidden" ? <> {NEXT_STEP[p.reason]} so they count.> : null}>} How points work
+
{p.season.ended ? "No points this season." : <>No points yet this season. Launch something people hold, or find tokens early and hold them.{step ? <> {NEXT_STEP[step]} so they count.> : null}>} How points work
)}
);
diff --git a/app/src/components/points/YourStanding.tsx b/app/src/components/points/YourStanding.tsx
index 9085d82a..a0d4bf4f 100644
--- a/app/src/components/points/YourStanding.tsx
+++ b/app/src/components/points/YourStanding.tsx
@@ -11,8 +11,8 @@ export default function YourStanding({ board }: { board: "creator" | "scout" })
if (!address || !p?.season || !p.me) return null;
const ended = p.season.ended;
const final = p.season.final;
- // only a step the wallet can take is offered: a reason the API keeps back (a moderator's call) leaves it neutral
- const actionable = p.reason === "no_profile" || p.reason === "not_verified" || p.reason === "account_too_new" || p.reason === "few_followers";
+ // only a step the wallet can take is offered (a hidden profile has none; a moderator's other calls are never told)
+ const actionable = Boolean(p.reason && p.reason !== "hidden");
const me = p.me;
const points = board === "creator" ? me.creator : me.scout;
const rank = board === "creator" ? me.rank_creator : me.rank_scout;
diff --git a/app/src/components/points/usePoints.ts b/app/src/components/points/usePoints.ts
index a1e034af..8a67ae91 100644
--- a/app/src/components/points/usePoints.ts
+++ b/app/src/components/points/usePoints.ts
@@ -2,9 +2,9 @@
import { useEffect, useState } from "react";
import type { WalletPoints } from "@/lib/points/server";
-import type { NotEligibleReason } from "@/lib/points/score";
+import { PUBLIC_REASONS, type PublicReason } from "@/lib/points/score";
-export type PointsState = { season: { name: string; starts_at: string; ends_at: string; ended: boolean; final: boolean } | null; me: WalletPoints | null; reason: NotEligibleReason | null; at: number } | null;
+export type PointsState = { season: { name: string; starts_at: string; ends_at: string; ended: boolean; final: boolean } | null; me: WalletPoints | null; reason: PublicReason | null; at: number } | null;
const REFRESH_MS = 5 * 60_000; // points are recomputed hourly; a page left open picks up a publish or a new run
const RETRY_MS = [10_000, 30_000, 60_000]; // after a failed read, then back to the normal refresh
@@ -30,7 +30,9 @@ export function usePoints(wallet: string | undefined): PointsState {
try {
const r = await fetch(`/api/points${me ? `?wallet=${me}` : ""}`, { cache: "no-store" });
if (!r.ok) throw new Error(String(r.status));
- const v = { ...((await r.json()) as Omit, "at">), at: Date.now() };
+ const d = (await r.json()) as Omit, "at">;
+ // only a reason the card knows how to say is kept
+ const v = { ...d, reason: PUBLIC_REASONS.includes(d.reason as PublicReason) ? d.reason : null, at: Date.now() };
if (!alive) return; // checked after the body is read, not before
setState({ wallet: me, v });
failures = 0;
diff --git a/app/src/lib/launchpad/museworld.test.ts b/app/src/lib/launchpad/museworld.test.ts
index b43e9368..8eefa3c3 100644
--- a/app/src/lib/launchpad/museworld.test.ts
+++ b/app/src/lib/launchpad/museworld.test.ts
@@ -55,7 +55,9 @@ test("time-weighted price: a one-block push barely moves a 30-minute average", (
test("server: priced from its own GITLAWB pool, guarded by the average, fails soft, ranks in USD sorts", () => {
const q = src("queries.ts");
- assert.match(q, /if \(q\.key === "museworld"\) return \{ \.\.\.q, usd: museworldUsdNow \};/);
+ // quoteInfo prices from one snapshot of the loaded prices (priceState), MUSEWORLD from museworldUsdNow
+ assert.match(q, /if \(q\.key === "museworld"\) return \{ \.\.\.q, usd: st\.museworld \};/);
+ assert.match(q, /const priceState = \(\): PriceState => \(\{[^\n]*museworld: museworldUsdNow,/);
assert.match(q, /row\.quote !== GITLAWB_ADDRESS\.toLowerCase\(\)\) return null;/, "only the MUSEWORLD/GITLAWB pool may price it");
assert.match(q, /reconcileGitlawbUsd\(spot > 0 \? spot : null, twap\)/);
assert.match(q, /perMuseworld \* gitlawbUsd/);
diff --git a/app/src/lib/launchpad/queries.ts b/app/src/lib/launchpad/queries.ts
index 3b189d90..8e85956b 100644
--- a/app/src/lib/launchpad/queries.ts
+++ b/app/src/lib/launchpad/queries.ts
@@ -114,16 +114,23 @@ let museworldUsdNow: number | null = null;
/** GITLAWB USD for this request (filled by `withStocks`; null = unknown → no USD, 0 weight in USD sorts). */
let gitlawbUsdNow: number | null = null;
-/** Server-side quote resolution: static ETH/USDG/GITLAWB/TWIG (GITLAWB gets the live price, TWIG the same one), else a registry stock, else an unlisted quote. */
-function quoteInfo(chain: ChainKey, address: string): Quote {
+/** The quote prices in use, as one value: what `withStocks` last loaded (each piece is replaced, never edited). */
+type PriceState = { gitlawb: number | null; museworld: number | null; stocks: Map; quoteTokens: Map; stockSymbols: Map };
+const priceState = (): PriceState => ({ gitlawb: gitlawbUsdNow, museworld: museworldUsdNow, stocks: stockUsdNow, quoteTokens: quoteTokensNow, stockSymbols: stockSymbolsNow });
+
+/**
+ * Server-side quote resolution: static ETH/USDG/GITLAWB/TWIG (GITLAWB gets the live price, TWIG the same one), else a
+ * registry stock, else an unlisted quote. Priced from `st` (the prices loaded now unless a snapshot is passed).
+ */
+function quoteInfo(chain: ChainKey, address: string, st: PriceState = priceState()): Quote {
const q = staticQuoteInfo(chain, address);
- const linked = gitlawbLinkedUsd(q.key, gitlawbUsdNow); // GITLAWB, and TWIG at the same price
+ const linked = gitlawbLinkedUsd(q.key, st.gitlawb); // GITLAWB, and TWIG at the same price
if (linked !== undefined) return { ...q, usd: linked };
- if (q.key === "museworld") return { ...q, usd: museworldUsdNow };
+ if (q.key === "museworld") return { ...q, usd: st.museworld };
if (q.key !== "other") return q;
- const st = stockByAddress(chain, address);
- if (st) return { key: "stock", address: st.address as Quote["address"], symbol: st.symbol, decimals: st.decimals, usd: stockUsdNow.get(st.address) ?? null, name: st.name, logo: st.logo };
- return unlistedQuote(address, quoteTokensNow.get(`${chainIdOf(chain)}:${address.toLowerCase()}`) ?? null, stockSymbolsNow.get(chain) ?? []);
+ const stock = stockByAddress(chain, address);
+ if (stock) return { key: "stock", address: stock.address as Quote["address"], symbol: stock.symbol, decimals: stock.decimals, usd: st.stocks.get(stock.address) ?? null, name: stock.name, logo: stock.logo };
+ return unlistedQuote(address, st.quoteTokens.get(`${chainIdOf(chain)}:${address.toLowerCase()}`) ?? null, st.stockSymbols.get(chain) ?? []);
}
/**
@@ -800,8 +807,10 @@ export async function getTrending(ethUsd: number | null = null): Promise { key: string; decimals: number; usd: number | null }> {
await withStocks();
+ // one snapshot for the caller's whole run: a page refresh loading new prices meanwhile must not change it halfway
+ const snap = priceState();
return (chain, quote) => {
- const q = quoteInfo(chain, quote);
+ const q = quoteInfo(chain, quote, snap);
return { key: q.key, decimals: q.decimals, usd: quoteUsd(q, ethUsd) };
};
}
diff --git a/app/src/lib/launchpad/twig.test.ts b/app/src/lib/launchpad/twig.test.ts
index 13db027b..db85879e 100644
--- a/app/src/lib/launchpad/twig.test.ts
+++ b/app/src/lib/launchpad/twig.test.ts
@@ -58,7 +58,8 @@ test("TWIG's USD is exactly GITLAWB's, and unknown while GITLAWB's is", () => {
test("server: TWIG takes GITLAWB's price in rows, sorts and the quotes API; never its own thin pool", () => {
const q = src("queries.ts");
- assert.match(q, /const linked = gitlawbLinkedUsd\(q\.key, gitlawbUsdNow\);[^\n]*\n if \(linked !== undefined\) return \{ \.\.\.q, usd: linked \};/);
+ assert.match(q, /const linked = gitlawbLinkedUsd\(q\.key, st\.gitlawb\);[^\n]*\n if \(linked !== undefined\) return \{ \.\.\.q, usd: linked \};/);
+ assert.match(q, /const priceState = \(\): PriceState => \(\{ gitlawb: gitlawbUsdNow,/, "the snapshot carries the live GITLAWB price");
assert.match(q, /const isGitlawbPriced = \(\) => db`\(\$\{isGitlawb\(\)\} OR \$\{quoteArms\("twig"\)\}\)`;/);
assert.match(q, /WHEN \$\{isGitlawbPriced\(\)\} THEN \$\{gitlawbFactor\}::double precision/, "TWIG ranks at GITLAWB's price in the USD sorts");
assert.match(q, /if \(opts\.filter === "twig"\) conds\.push\(db`\$\{quoteArms\("twig"\)\}`\);/);
diff --git a/app/src/lib/launchpad/unlisted-quote.test.ts b/app/src/lib/launchpad/unlisted-quote.test.ts
index ae6b87ba..f66ff512 100644
--- a/app/src/lib/launchpad/unlisted-quote.test.ts
+++ b/app/src/lib/launchpad/unlisted-quote.test.ts
@@ -76,7 +76,8 @@ test("symbols are cleaned: ASCII word characters only, bounded, no control or ma
test("the server resolves unlisted quotes from bb_quote_tokens, fails soft, and keeps them out of Trending", () => {
const q = src("queries.ts");
- assert.match(q, /return unlistedQuote\(address, quoteTokensNow\.get\(`\$\{chainIdOf\(chain\)\}:\$\{address\.toLowerCase\(\)\}`\)/);
+ assert.match(q, /return unlistedQuote\(address, st\.quoteTokens\.get\(`\$\{chainIdOf\(chain\)\}:\$\{address\.toLowerCase\(\)\}`\)/);
+ assert.match(q, /const priceState = \(\): PriceState => \(\{[^\n]*quoteTokens: quoteTokensNow,/);
assert.match(q, /quoteTokensNow = await memo\("quote-tokens"/);
assert.match(q, /\/\* fail soft \(table not migrated yet/);
assert.match(q, /rankTrending\(rows\.filter\(\(r\) => r\.quote_key !== "other"\)/);
diff --git a/app/src/lib/points/auth.test.ts b/app/src/lib/points/auth.test.ts
index 444728d6..a972c2da 100644
--- a/app/src/lib/points/auth.test.ts
+++ b/app/src/lib/points/auth.test.ts
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
-import { buildPointsAdminMessage, normalizeSeasonDays } from "./auth.ts";
+import { buildPointsAdminMessage, normalizeSeasonDays, normalizeSeasonId } from "./auth.ts";
const base = { wallet: "0xAbC0000000000000000000000000000000000001", nonce: "a".repeat(32), ts: 1_760_000_000_000 };
@@ -25,3 +25,15 @@ test("other actions carry no length line", () => {
assert.doesNotMatch(buildPointsAdminMessage({ ...base, action, days: 5 }), /Season length/, action);
}
});
+
+test("publish, hide and end sign the season they act on: a replay cannot be pointed at another season", () => {
+ for (const action of ["publish", "unpublish", "end"] as const) {
+ const m = buildPointsAdminMessage({ ...base, action, seasonId: 2 });
+ assert.match(m, /Season: 2/, action);
+ assert.notEqual(buildPointsAdminMessage({ ...base, action, seasonId: 1 }), m, `${action}: season 1 is another message`);
+ }
+ for (const action of ["preview", "start", "recompute"] as const) assert.doesNotMatch(buildPointsAdminMessage({ ...base, action, seasonId: 2 }), /Season: /, action);
+ assert.equal(normalizeSeasonId("3"), 3);
+ assert.equal(normalizeSeasonId(2.7), 2);
+ for (const bad of [undefined, null, "x", -1, 0, Number.NaN]) assert.equal(normalizeSeasonId(bad), 0, String(bad));
+});
diff --git a/app/src/lib/points/auth.ts b/app/src/lib/points/auth.ts
index 7a36b83a..7d652622 100644
--- a/app/src/lib/points/auth.ts
+++ b/app/src/lib/points/auth.ts
@@ -11,16 +11,26 @@ export function normalizeSeasonDays(raw: unknown): number {
return Math.min(90, Math.max(1, Math.trunc(Number(raw) || 28)));
}
+/** The actions that act on one named season (its id is signed with them). */
+export const SEASON_ACTIONS: readonly PointsAdminAction[] = ["publish", "unpublish", "end"];
+/** A season id as signed: a positive whole number, else 0 (no season). */
+export function normalizeSeasonId(raw: unknown): number {
+ const n = Math.trunc(Number(raw));
+ return Number.isFinite(n) && n > 0 ? n : 0;
+}
+
/**
- * The message an admin wallet signs for one points action. Starting a season also names its length, so a signed
- * request cannot be replayed with a different one.
+ * The message an admin wallet signs for one points action. Starting a season also names its length, and publish,
+ * hide and end name the season they act on, so a signed request can be replayed with neither a different length
+ * nor a different season.
*/
-export function buildPointsAdminMessage(p: { action: PointsAdminAction; wallet: string; nonce: string; ts: number; days?: number }): string {
+export function buildPointsAdminMessage(p: { action: PointsAdminAction; wallet: string; nonce: string; ts: number; days?: number; seasonId?: number }): string {
return [
`openlaunch.lol points`,
``,
`Action: ${p.action}`,
...(p.action === "start" ? [`Season length: ${normalizeSeasonDays(p.days)} days`] : []),
+ ...(SEASON_ACTIONS.includes(p.action) ? [`Season: ${normalizeSeasonId(p.seasonId)}`] : []),
`Admin: ${p.wallet.toLowerCase()}`,
`Nonce: ${p.nonce}`,
`Issued: ${new Date(p.ts).toISOString()}`,
diff --git a/app/src/lib/points/blocks.test.ts b/app/src/lib/points/blocks.test.ts
new file mode 100644
index 00000000..db601d12
--- /dev/null
+++ b/app/src/lib/points/blocks.test.ts
@@ -0,0 +1,45 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { lastBlockBetween } from "./blocks.ts";
+
+/** A fake chain: block b's time (ms), and a counter of reads. */
+function chain(times: (b: number) => number) {
+ let reads = 0;
+ return { reads: () => reads, timeOf: async (b: bigint) => (reads++, times(Number(b))) };
+}
+const check = async (c: ReturnType, lo: number, hi: number, ms: number, times: (b: number) => number) => {
+ const b = Number(await lastBlockBetween(BigInt(lo), times(lo), BigInt(hi), times(hi), ms, c.timeOf));
+ assert.ok(times(b) < ms && times(b + 1) >= ms, `block ${b}: ${times(b)} < ${ms} <= ${times(b + 1)}`);
+ return b;
+};
+
+test("steady 2 s blocks over 3 million blocks: found in a few reads", async () => {
+ const t = (b: number) => 1_700_000_000_000 + b * 2_000;
+ for (const ms of [t(1_234_567) + 1, t(2_999_998), t(5) + 1_999]) {
+ const c = chain(t);
+ await check(c, 0, 3_000_000, ms, t);
+ assert.ok(c.reads() <= 4, `${c.reads()} reads`);
+ }
+});
+
+test("uneven block times (bursts, a long halt): still exact, and never more reads than about two per halving", async () => {
+ // fast blocks, then a 6-hour halt at block 400,000, then slow blocks
+ const t = (b: number) => (b < 400_000 ? b * 250 : 400_000 * 250 + 6 * 3_600_000 + (b - 400_000) * 12_000);
+ for (const ms of [t(399_999) + 1, t(400_000), t(400_000) - 1, t(123_456) + 7, t(900_000) + 11_999]) {
+ const c = chain(t);
+ await check(c, 0, 1_000_000, ms, t);
+ assert.ok(c.reads() <= 2 * Math.ceil(Math.log2(1_000_000)), `${c.reads()} reads`);
+ }
+});
+
+test("blocks sharing a timestamp: the last one before the time, not one of the same second", async () => {
+ const t = (b: number) => Math.floor(b / 4) * 1_000; // four blocks a second
+ const c = chain(t);
+ const b = await check(c, 0, 10_000, 5_000, t); // blocks 20..23 are at 5,000 ms
+ assert.equal(b, 19);
+});
+
+test("a range that does not bracket the time is refused", async () => {
+ const t = (b: number) => b * 1_000;
+ await assert.rejects(lastBlockBetween(10n, t(10), 20n, t(20), t(25), chain(t).timeOf));
+});
diff --git a/app/src/lib/points/blocks.ts b/app/src/lib/points/blocks.ts
new file mode 100644
index 00000000..b4d87be1
--- /dev/null
+++ b/app/src/lib/points/blocks.ts
@@ -0,0 +1,23 @@
+/**
+ * The last block whose time is before `ms`, given `lo` (time before `ms`) and `hi` (time at or after it), reading
+ * block times through `timeOf`. Interpolated on time (block times are near constant, so a few reads land it),
+ * halving whenever a guess did not halve the range, so the worst case stays logarithmic.
+ */
+export async function lastBlockBetween(lo: bigint, tLo: number, hi: bigint, tHi: number, ms: number, timeOf: (b: bigint) => Promise): Promise {
+ if (!(tLo < ms && ms <= tHi) || hi <= lo) throw new Error("lastBlockBetween: the range does not bracket the time");
+ let halve = false;
+ while (hi - lo > 1n) {
+ const width = hi - lo;
+ let mid = (lo + hi) / 2n;
+ if (!halve && tHi > tLo) {
+ mid = lo + BigInt(Math.floor((Number(width) * (ms - tLo)) / (tHi - tLo)));
+ if (mid <= lo) mid = lo + 1n;
+ if (mid >= hi) mid = hi - 1n;
+ }
+ const t = await timeOf(mid);
+ if (t < ms) [lo, tLo] = [mid, t];
+ else [hi, tHi] = [mid, t];
+ halve = !halve && (hi - lo) * 2n > width; // a guess that did not halve the range: halve next
+ }
+ return lo;
+}
diff --git a/app/src/lib/points/score.test.ts b/app/src/lib/points/score.test.ts
index 0e91aa8b..b470e6fd 100644
--- a/app/src/lib/points/score.test.ts
+++ b/app/src/lib/points/score.test.ts
@@ -17,7 +17,7 @@ const tokensFor = (ethIn: number) => BigInt(Math.round((ethIn * 2000) / 0.001))
type Move = ScoreInput["moves"][number];
function input(over: Partial = {}): ScoreInput {
- return { seasonStart: START, seasonEnd: START + 28 * DAY, now: NOW, launches: [L], swaps: [], firstBuys: [], moves: [], launcherBuys: new Map(), linked: new Map(), system: new Set(["0xpool"]), eligible: new Set(), flagged: new Set(), ...over };
+ return { seasonStart: START, seasonEnd: START + 28 * DAY, now: NOW, launches: [L], swaps: [], firstBuys: [], moves: [], launcherBuys: new Map(), linked: new Map(), system: new Set(["0xpool"]), eligible: new Set(), flagged: new Set(), seasonStartBlocks: new Map([["base", 1_000]]), ...over };
}
let n = 0;
/** a wallet that bought `ethIn` at `t` (the swap, then its token transfer in) and later sold all but `keep` of it */
@@ -246,3 +246,62 @@ test("ranks only eligible wallets, ties share a rank; why lines read plainly", (
assert.deepEqual([...rankBy(scores, "creator", new Set(["a", "b"])).entries()], [["a", 1], ["b", 1]]);
assert.equal(whyLine("creator", scores[0]), "2 verified holders · 1 holder · $12.50 fees from verified traders");
});
+
+// ── review on PR 85 ──────────────────────────────────────────────────────────
+test("dump through a side wallet: moving the bought tokens out in the season and selling elsewhere is a dump too", () => {
+ const b = buyer(w(1), START + DAY, 0.01);
+ const bought = new Map([[L.key, [{ tx: "0xcb", tokenRaw: 1_000_000n * E18 }]]]);
+ const boughtIn: Move = { key: L.key, wallet: CREATOR, block: 50, logIndex: 1, tx: "0xcb", delta: 1_000_000n * E18 };
+ const toSide: Move = { key: L.key, wallet: CREATOR, block: 3000, logIndex: 5, tx: "0xside", delta: -700_000n * E18 }; // no swap by the creator
+ const sideSell: ScoreSwap = { key: L.key, trader: "0xside", isBuy: false, quoteRaw: eth(0.1), tokenRaw: 700_000n * E18, block: 3001, logIndex: 1, time: START + 2 * DAY, tx: "0xss" };
+ const r = scoreSeason(build([b], { swaps: [b.swap, sideSell], launcherBuys: bought, moves: [...b.moves, boughtIn, toSide] }));
+ assert.equal(r.wallets.get(CREATOR)?.creator ?? 0, 0);
+ // the same transfer before the season is no dump of this season
+ const early: Move = { ...toSide, block: 500 }; // the season starts at block 1,000 in these tests
+ const r2 = scoreSeason(build([b], { swaps: [b.swap], launcherBuys: bought, moves: [...b.moves, boughtIn, early] }));
+ assert.ok((r2.wallets.get(CREATOR)?.creator ?? 0) > 0, "moved before the season: this season's points stand");
+ // burning them is not moving them out
+ const burned = scoreSeason(build([b], { swaps: [b.swap], launcherBuys: bought, moves: [...b.moves, boughtIn, { ...toSide, burn: true }] }));
+ assert.ok((burned.wallets.get(CREATOR)?.creator ?? 0) > 0, "a burn is no dump");
+});
+
+test("best 3 per launch day are ranked with the fee cap applied: one big fee payer cannot push out real holders", () => {
+ const launches = ["0xa", "0xb", "0xc", "0xd"].map((t, i) => ({ ...L, key: `base:${t}`, launchTime: START - DAY + i }));
+ // 0xa: one eligible trader paying $20 of fees in a day (200 points uncapped, 50 capped), no holders worth more
+ const whale = buyer(w(900), START + DAY, 20 / 0.01 / 2000, 1, launches[0]); // $2,000 buy → $20 fee at 1%
+ // 0xb..0xd: 3 eligible holders each (45 each after a week): 135 apiece. 0xa is worth 45 (the whale holds) + 50
+ // (its fees capped) = 95; ranked uncapped it looked like 245 and pushed one of the 135s out
+ const fans = [3, 3, 3].flatMap((count, i) => Array.from({ length: count }, (_, j) => buyer(w(100 * (i + 1) + j), START + DAY, 0.003, 1, launches[i + 1])));
+ const eligible = new Set([w(900), ...fans.map((f) => f.first.wallet)]);
+ const r = scoreSeason(build([whale, ...fans], { launches, eligible }));
+ const c = r.wallets.get(CREATOR)!;
+ assert.equal(c.creatorWhy.tokens, 3);
+ // the true best three are 0xb, 0xc and 0xd (3 × 135 = 405, plus their holders' own small fees), not 0xa's 95
+ assert.ok(c.creator >= 405, `creator ${c.creator}`);
+ assert.equal(c.creatorWhy.verifiedHolders, 9, "every fan counted; the whale's token was the one cut");
+});
+
+test("holds come only from a token the wallet first bought in the season (with a real first buy)", () => {
+ // held from before the season, bought again in it: no hold point for the second buy
+ const before = buyer(w(1), START - DAY, 0.003);
+ const again = buyer(w(1), START + DAY, 0.003);
+ const r = scoreSeason(input({ swaps: [before.swap, again.swap], firstBuys: [before.first], moves: [...before.moves, ...again.moves] }));
+ assert.equal(r.wallets.get(w(1))?.scoutWhy.holds ?? 0, 0);
+ // a first buy that was not a real one (under $5), then a real-sized buy: still no hold point
+ const tiny = buyer(w(2), START + DAY, 0.001); // $2
+ const bigger = buyer(w(2), START + 2 * DAY, 0.003);
+ const r2 = scoreSeason(input({ swaps: [tiny.swap, bigger.swap], firstBuys: [tiny.first], moves: [...tiny.moves, ...bigger.moves] }));
+ assert.equal(r2.wallets.get(w(2))?.scoutWhy.holds ?? 0, 0);
+});
+
+test("a buy whose tokens the wallet never received (a relayer or bundler the swap was credited to) is no real buy", () => {
+ const real = Array.from({ length: 52 }, (_, i) => buyer(w(i + 1), START + DAY + 100 + i, 0.003, 1, L, 1100 + i));
+ // a relayer credited with 25 early swaps: the tokens went to someone else, so no move into the relayer
+ const relayed = Array.from({ length: 25 }, (_, i) => {
+ const b = buyer(`0xrelay${i}`, START + DAY + i, 0.003, 1, L, 1000 + i);
+ return { ...b, moves: b.moves.map((m) => ({ ...m, wallet: `0xuser${i}` })) };
+ });
+ const r = scoreSeason(build([...relayed, ...real]));
+ assert.equal(r.wallets.get("0xrelay0")?.scoutWhy.early ?? 0, 0, "the relayer takes no early slot");
+ assert.equal(r.wallets.get(w(1))!.scoutWhy.early, 1, "the first real buyers keep theirs");
+});
diff --git a/app/src/lib/points/score.ts b/app/src/lib/points/score.ts
index b7b92d41..ad25a7d5 100644
--- a/app/src/lib/points/score.ts
+++ b/app/src/lib/points/score.ts
@@ -5,8 +5,10 @@
* built on two definitions that cost real money to fake:
*
* real buyer = a wallet whose FIRST buy of a token was ≥ $5, outside the sniper window (the launch block + 3, and
- * the first 12 seconds), and that is not the launcher, a fee recipient, a protocol address, a wallet
- * that got the token by transfer from the launcher or a recipient, or a wallet kept off points
+ * the first 12 seconds), that received the tokens of that buy itself (a relayer or bundler a swap was
+ * credited to, or a router forwarding to someone else, is not a buyer), and that is not the launcher,
+ * a fee recipient, a protocol address, a wallet that got the token by transfer from the launcher or a
+ * recipient, or a wallet kept off points
* real holder = a real buyer with ≥ $5 (≥ $1 if their profile is points-eligible) of that first buy's lot still left
*
* "Held" is judged per buy, from the transfer history: every incoming transfer is a lot, and every outgoing one (a sell,
@@ -21,12 +23,15 @@
* else 5; a week or more ago: half again. A holder counts once per creator (their best token).
* - 10 per $1 of fees on buys ≥ $5 by points-eligible outside traders, for each buy half of which has been held ever
* since (a round trip earns nothing); at most 50 per trader per creator per day
- * - a token scores 0 if its creator sold during the season and less than half of their own buys is left
- * - of the tokens a wallet launched on one UTC day, only the best 3 count
+ * - a token scores 0 if its creator moved tokens out during the season (a sell, or a transfer to any wallet, a
+ * side wallet that then sells included; burning is not moving out) and less than half of their own buys is
+ * left
+ * - of the tokens a wallet launched on one UTC day, only the best 3 count (ranked with the fee cap applied)
* Scout (traders)
* - one of the first 25 real buyers of a token that now has 50+ real holders, buying in the season: 100, and 50
* more while still holding half of that first buy
- * - 5 per token first bought in the season (≥ $5) and still half-held a day later; at most 20 tokens per UTC day
+ * - 5 per token the wallet first bought in the season (a real buy) and still half-held a day later; at most 20
+ * tokens per UTC day
* - 10 per $1 of fees paid on buys ≥ $5 of other people's tokens with 20+ real holders (5+ of them eligible), for
* each buy half of which has been held ever since; at most 200 a day
*
@@ -85,8 +90,11 @@ export type ScoreInput = {
/** swaps inside the season, any order */
swaps: readonly ScoreSwap[];
firstBuys: readonly FirstBuy[];
- /** every balance change (raw, 18 decimals) of buyers and launchers of the tokens in scope, from the transfer history */
- moves: readonly { key: string; wallet: string; block: number; logIndex: number; tx: string; delta: bigint }[];
+ /**
+ * every balance change (raw, 18 decimals) of buyers and launchers of the tokens in scope, from the transfer history;
+ * `burn` marks tokens sent to a burn address (0x0, 0x…dead)
+ */
+ moves: readonly { key: string; wallet: string; block: number; logIndex: number; tx: string; delta: bigint; burn?: boolean }[];
/** the launcher's own buys of each token, all-time (transaction + tokens bought) */
launcherBuys: ReadonlyMap;
/** per token: wallets that received it by transfer from the launcher or a fee recipient */
@@ -96,6 +104,11 @@ export type ScoreInput = {
eligible: ReadonlySet;
/** kept off points by an admin: never points, never anyone's buyer, holder or trader */
flagged: ReadonlySet;
+ /**
+ * per chain (the key's prefix, "base"): the first block of the season. Transfers carry blocks, not times, so this is
+ * what tells a creator's in-season outflows from earlier ones. A chain missing here counts only swap sells.
+ */
+ seasonStartBlocks: ReadonlyMap;
};
export type CreatorBreakdown = { tokens: number; verifiedHolders: number; holders: number; feesUsd: number; dumped: number };
@@ -130,6 +143,7 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
// lots per (token, wallet): each incoming transfer is a lot; each outgoing one uses up the newest lots first (LIFO).
// A lot only ever shrinks, so what is left of it at the end is the least it ever held. Linear in the moves.
const lotsByTx = new Map(); // `${key}|${wallet}|${tx}` → what is left of the lots that tx brought in
+ const receivedByTx = new Map(); // `${key}|${wallet}|${tx}` → what that tx brought in at all
{
const byPair = new Map>();
for (const m of input.moves) {
@@ -148,6 +162,7 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
const lot = { tx: m.tx, left: m.delta };
stack.push(lot);
all.push(lot);
+ receivedByTx.set(`${k}|${m.tx}`, (receivedByTx.get(`${k}|${m.tx}`) ?? 0n) + m.delta);
} else if (m.delta < 0n) {
let out = -m.delta;
while (out > 0n && stack.length) {
@@ -170,11 +185,15 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
return left > 0n && left * 2n >= boughtRaw;
};
+ /** The wallet received at least half of what a buy bought, in that buy's own transaction. */
+ const received = (key: string, w: string, tx: string, boughtRaw: bigint) => (receivedByTx.get(`${key}|${w}|${tx}`) ?? 0n) * 2n >= boughtRaw;
+
// real buyers per token, in buy order
const realBuyers = new Map();
for (const f of input.firstBuys) {
const l = launches.get(f.key);
if (!l || insider(l, f.wallet) || sniper(l, f.block, f.time) || quoteUsd(l, f.quoteRaw) < RULES.minTradeUsd) continue;
+ if (!received(f.key, f.wallet, f.tx, f.tokenRaw)) continue; // the tokens went to someone else: not this wallet's buy
const list = realBuyers.get(f.key) ?? [];
list.push(f);
realBuyers.set(f.key, list);
@@ -234,13 +253,22 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
const fee = (quoteUsd(l, s.quoteRaw) * l.lpFee) / 1_000_000;
if (fee > 0) ensure(l).fees.push({ trader: s.trader, day: utcDay(s.time), usd: fee });
}
+ // the creator's in-season outflows of each token: a sell, or tokens moved to any other wallet (one that then sells
+ // included; a burn is not one), told from earlier ones by the season's first block on that chain
+ const creatorOut = new Set(creatorSold);
+ for (const m of input.moves) {
+ const l = launches.get(m.key);
+ if (!l || m.wallet !== l.launcher || m.delta >= 0n || m.burn) continue;
+ const from = input.seasonStartBlocks.get(m.key.split(":")[0]);
+ if (from !== undefined && m.block >= from) creatorOut.add(m.key);
+ }
for (const t of perToken.values()) {
const buys = input.launcherBuys.get(t.l.key) ?? [];
const bought = buys.reduce((a, b) => a + b.tokenRaw, 0n);
const left = buys.reduce((a, b) => a + leftOf(t.l.key, t.l.launcher, b.tx), 0n);
- // a dump = sold during the season and less than half of the creator's own buys is left (fee tokens that came in
- // after the buys are used up first, so selling those is not one)
- if (bought > 0n && creatorSold.has(t.l.key) && left * 2n < bought) t.dumped = true;
+ // a dump = tokens moved out during the season and less than half of the creator's own buys is left (fee tokens
+ // that came in after the buys are used up first, so selling those is not one)
+ if (bought > 0n && creatorOut.has(t.l.key) && left * 2n < bought) t.dumped = true;
}
// per creator: each holder once (their best token), fees capped per trader per day, best 3 tokens per launch day
const byCreator = new Map();
@@ -252,16 +280,12 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
}
for (const [creator, tokens] of byCreator) {
const s = get(creator);
- const value = new Map();
- const counted = new Set();
- const feeUsed = new Map();
- // holders: each holder once, on the token where they are worth the most. Two passes: pick each launch day's best
- // tokens with every holder on their best token, then put each holder on their best token among those that count,
- // so a holder whose favourite token was cut still counts on another
- const assign = (allowed: (t: TokenCreator) => boolean) => {
+ const live = tokens.filter((t) => !t.dumped);
+ s.creatorWhy.dumped = tokens.length - live.length;
+ // each holder once, on the token where they are worth the most among `pool`
+ const assign = (pool: readonly TokenCreator[]) => {
const best = new Map();
- for (const t of tokens) {
- if (t.dumped || !allowed(t)) continue;
+ for (const t of pool) {
for (const h of t.holders) {
const cur = best.get(h.wallet);
if (!cur || h.pts > cur.pts) best.set(h.wallet, { t, pts: h.pts, eligible: h.eligible });
@@ -269,53 +293,54 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
}
return best;
};
- const firstPass = new Map();
- for (const [, h] of assign(() => true)) firstPass.set(h.t, (firstPass.get(h.t) ?? 0) + h.pts);
- const kept = new Set();
- const daysFirst = new Map();
- for (const t of tokens) {
- const d = utcDay(t.l.launchTime);
- daysFirst.set(d, [...(daysFirst.get(d) ?? []), t]);
- }
- const rough = (t: TokenCreator) => (firstPass.get(t) ?? 0) + t.fees.reduce((a, f) => a + f.usd * RULES.creatorFeePointsPerUsd, 0);
- for (const list of daysFirst.values()) for (const t of [...list].sort((a, b) => rough(b) - rough(a)).slice(0, RULES.bestTokensPerDay)) kept.add(t);
- const holderBest = assign((t) => kept.has(t));
- for (const [, h] of holderBest) value.set(h.t, (value.get(h.t) ?? 0) + h.pts);
- const feeUsd = new Map();
- for (const t of tokens) {
- if (t.dumped || !kept.has(t)) continue;
+ /** Fee points of one token, at most 50 per trader per day, counting what `used` already holds for this creator. */
+ const feePoints = (t: TokenCreator, used: Map) => {
+ let pts = 0;
+ let usd = 0;
for (const f of t.fees) {
const k = `${f.trader}|${f.day}`;
- const used = feeUsed.get(k) ?? 0;
- const pts = Math.min(f.usd * RULES.creatorFeePointsPerUsd, RULES.creatorFeeCapPerTraderDay - used);
- if (pts <= 0) continue;
- feeUsed.set(k, used + pts);
- value.set(t, (value.get(t) ?? 0) + pts);
- feeUsd.set(t, (feeUsd.get(t) ?? 0) + f.usd);
+ const already = used.get(k) ?? 0;
+ const p = Math.min(f.usd * RULES.creatorFeePointsPerUsd, RULES.creatorFeeCapPerTraderDay - already);
+ if (p <= 0) continue;
+ used.set(k, already + p);
+ pts += p;
+ usd += f.usd;
}
- }
+ return { pts, usd };
+ };
+ // the best 3 tokens of each launch day, ranked by what each would actually be worth: its holders (each on their
+ // best token) and its fees with the per-trader-per-day cap applied (an uncapped estimate let one big fee payer
+ // push a token with real holders out of the 3)
+ const firstPass = new Map();
+ for (const [, h] of assign(live)) firstPass.set(h.t, (firstPass.get(h.t) ?? 0) + h.pts);
+ const estimate = new Map(live.map((t) => [t, (firstPass.get(t) ?? 0) + feePoints(t, new Map()).pts]));
const byDay = new Map();
- for (const t of tokens) {
- if (t.dumped) s.creatorWhy.dumped++;
+ for (const t of live) {
const d = utcDay(t.l.launchTime);
- const list = byDay.get(d) ?? [];
- list.push(t);
- byDay.set(d, list);
+ byDay.set(d, [...(byDay.get(d) ?? []), t]);
}
- for (const list of byDay.values()) {
- list.sort((a, b) => (value.get(b) ?? 0) - (value.get(a) ?? 0));
- for (const t of list.slice(0, RULES.bestTokensPerDay)) {
- const v = value.get(t) ?? 0;
- if (v <= 0) continue;
- s.creator += v;
- s.creatorWhy.tokens++;
- s.creatorWhy.feesUsd += feeUsd.get(t) ?? 0;
- for (const [w, h] of holderBest) {
- if (h.t !== t || counted.has(w)) continue;
- counted.add(w);
- if (h.eligible) s.creatorWhy.verifiedHolders++;
- else s.creatorWhy.holders++;
- }
+ const kept: TokenCreator[] = [];
+ for (const list of byDay.values()) kept.push(...[...list].sort((a, b) => (estimate.get(b) ?? 0) - (estimate.get(a) ?? 0)).slice(0, RULES.bestTokensPerDay));
+ kept.sort((a, b) => (estimate.get(b) ?? 0) - (estimate.get(a) ?? 0) || a.l.key.localeCompare(b.l.key));
+ // the count: each holder on their best kept token (a holder whose favourite was cut still counts on another), and
+ // fees with the cap shared across all of this creator's kept tokens
+ const holderBest = assign(kept);
+ const value = new Map();
+ for (const [, h] of holderBest) value.set(h.t, (value.get(h.t) ?? 0) + h.pts);
+ const feeUsed = new Map();
+ const counted = new Set();
+ for (const t of kept) {
+ const fees = feePoints(t, feeUsed);
+ const v = (value.get(t) ?? 0) + fees.pts;
+ if (v <= 0) continue;
+ s.creator += v;
+ s.creatorWhy.tokens++;
+ s.creatorWhy.feesUsd += fees.usd;
+ for (const [wallet, h] of holderBest) {
+ if (h.t !== t || counted.has(wallet)) continue;
+ counted.add(wallet);
+ if (h.eligible) s.creatorWhy.verifiedHolders++;
+ else s.creatorWhy.holders++;
}
}
}
@@ -330,21 +355,19 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
s.scoutWhy.early++;
}
}
- const firstSeasonBuy = new Map();
- for (const s of outside) {
- if (!s.isBuy) continue;
- const k = `${s.key}|${s.trader}`;
- const cur = firstSeasonBuy.get(k);
- if (!cur || s.time < cur.time) firstSeasonBuy.set(k, s);
- }
+ // holds: a token the wallet FIRST bought in the season (its first buy ever was a real buy, in the season), half of
+ // that buy held a day later; a later buy of a token held from before the season, or after a first buy that was not
+ // a real one (a sniper, under $5), earns nothing
+ const firstInSeason: FirstBuy[] = [];
+ for (const list of realBuyers.values()) for (const f of list) if (inSeason(f.time)) firstInSeason.push(f);
const holdsPerDay = new Map();
- for (const s of [...firstSeasonBuy.values()].sort((a, b) => a.time - b.time)) {
- if (end - s.time < DAY || !heldBuy(s)) continue;
- const dayKey = `${s.trader}|${utcDay(s.time)}`;
+ for (const f of firstInSeason.sort((a, b) => a.time - b.time || a.block - b.block || a.logIndex - b.logIndex)) {
+ if (end - f.time < DAY || !halfLeft(f.key, f.wallet, f.tx, f.tokenRaw)) continue;
+ const dayKey = `${f.wallet}|${utcDay(f.time)}`;
const n = holdsPerDay.get(dayKey) ?? 0;
if (n >= RULES.holdTokensPerDay) continue;
holdsPerDay.set(dayKey, n + 1);
- const w = get(s.trader);
+ const w = get(f.wallet);
w.scout += RULES.hold;
w.scoutWhy.holds++;
}
@@ -390,6 +413,9 @@ export function isEligible(p: { x_status: string; x_account_created: string | nu
/** Why a wallet is not on the board yet (for /me): the one step that would change it. */
export type NotEligibleReason = "no_profile" | "not_verified" | "account_too_new" | "few_followers" | "hidden" | "kept_off";
+/** The reasons anyone may be told (being kept off points is a moderator's call that never is). */
+export type PublicReason = Exclude;
+export const PUBLIC_REASONS: readonly PublicReason[] = ["no_profile", "not_verified", "account_too_new", "few_followers", "hidden"];
/** Why a wallet is not on the board yet, as the one step that would change it (null when it is eligible). */
export function notEligibleReason(p: { x_status: string; x_account_created: string | null; x_followers: number | null; points_flag: string | null; hidden: boolean; deleted_at?: string | null } | null, now: number): NotEligibleReason | null {
if (!p || p.deleted_at) return "no_profile";
diff --git a/app/src/lib/points/server.ts b/app/src/lib/points/server.ts
index 7390fe32..836df231 100644
--- a/app/src/lib/points/server.ts
+++ b/app/src/lib/points/server.ts
@@ -1,21 +1,33 @@
import "server-only";
import { isAddress } from "viem";
-import { CHAIN_KEYS, chainKeyOf, type ChainKey } from "@/lib/chainPublic";
+import { publicClient } from "@/lib/chain";
+import { CHAIN_KEYS, chainKeyOf, chainIdOf, type ChainKey } from "@/lib/chainPublic";
+import { CONFIGURED_CHAINS } from "@/lib/launchpad/config";
import { maybeDb, type Db } from "@/lib/db";
import { ethUsd } from "@/lib/launchpad/ethPrice";
import { DEAD_ADDR, ZERO_ADDR } from "@/lib/launchpad/holders";
-import { systemAddresses } from "@/lib/launchpad/indexer";
+import { launchDeployBlock, systemAddresses } from "@/lib/launchpad/indexer";
import { quotePerToken } from "@/lib/launchpad/math";
import { quotePricer } from "@/lib/launchpad/queries";
+import { lastBlockBetween } from "./blocks";
import { namesFor, type NameEntry } from "@/lib/profiles/server";
-import { isEligible, notEligibleReason, rankBy, scoreSeason, whyLine, type CreatorBreakdown, type FirstBuy, type NotEligibleReason, type ScoreLaunch, type ScoreSwap, type ScoutBreakdown } from "./score";
+import { isEligible, notEligibleReason, rankBy, scoreSeason, whyLine, type CreatorBreakdown, type FirstBuy, type PublicReason, type ScoreLaunch, type ScoreSwap, type ScoutBreakdown } from "./score";
/**
* Season points, the server half: load one season's chain data, score it (score.ts), replace the season's rows.
* Runs hourly from the sync loop (fire-and-forget, so indexing never waits for it) on ONE machine (session advisory
* lock on a reserved connection, which also runs the reads one after another so the request pool stays free), and on
* demand from /admin. Everything is recomputed from scratch, so a rule change or an admin flag applies to the whole
- * season on the next run. A run whose prices are incomplete writes nothing: the last board stays.
+ * season on the next run.
+ *
+ * Prices: without ETH, GITLAWB or TWIG a run writes nothing (the last board stays). Another quote that normally has a
+ * price (MUSEWORLD, a stock) but has none right now only takes its own tokens out of an hourly run, which the next run
+ * puts back; a run that would fix the FINAL standings writes nothing instead, so a price blip can never drop tokens
+ * from them for good. Tokens quoted in an unlisted pair never count (they have no price to hold anything to).
+ *
+ * Final standings are fixed only by a compute that read everything through the season's end: the clock passing the
+ * end is not enough, every indexer must have read past it too (computed_until is capped at the least of their
+ * cursors), and every read stops at the season's last block, so a late final compute sees the season as it ended.
*/
export type Season = { id: number; slug: string; name: string; starts_at: string; ends_at: string; public: boolean; computed_at: string | null; computed_until: string | null; published_at: string | null };
@@ -44,12 +56,11 @@ export function seasonEnded(s: Pick, now = Date.now()): boole
return new Date(s.ends_at).getTime() <= now;
}
-/** The final standings are fixed once a compute has read the data through the season's end. */
-/** Whether a season's final standings are in: a compute covered its end (the clock passing is not enough). */
+/**
+ * Whether a season's final standings are in: a compute covered its end, the indexers' progress included (the clock
+ * passing the end is not enough). After that the hourly and admin recomputes leave the season as it is.
+ */
export function seasonFinal(s: Pick): boolean {
- return isFinal(s);
-}
-function isFinal(s: Pick): boolean {
return Boolean(s.computed_until && new Date(s.computed_until).getTime() >= new Date(s.ends_at).getTime());
}
@@ -58,15 +69,102 @@ type LaunchRowRaw = { chain_id: number; token: string; launcher: string; recipie
export type ComputeResult = { wallets: number; eligible: number; ms: number } | { skipped: string };
+/** The chains the indexer reads (configured, with a deploy block): the ones a season's data comes from. */
+const indexedChains = () => CONFIGURED_CHAINS.filter((c) => launchDeployBlock(c) > 0n);
+
+/**
+ * The time every indexer has read through: the least of their cursor blocks' timestamps (ms), or null when one cannot
+ * be told (no cursor yet, a node that does not answer). Final standings wait for it to pass the season's end.
+ */
+async function indexedThrough(read: Db): Promise {
+ const rows = await read<{ chain_id: number; cursor_block: bigint }[]>`SELECT chain_id, cursor_block FROM bb_launch_sync_cursor`;
+ let least = Infinity;
+ for (const c of indexedChains()) {
+ const row = rows.find((r) => r.chain_id === chainIdOf(c));
+ if (!row || BigInt(row.cursor_block) <= 0n) return null;
+ try {
+ const b = await publicClient(c).getBlock({ blockNumber: BigInt(row.cursor_block) });
+ least = Math.min(least, Number(b.timestamp) * 1000);
+ } catch {
+ return null;
+ }
+ }
+ return Number.isFinite(least) ? least : null;
+}
+
+const lastBlockCache = new Map();
+/**
+ * The last block on a chain whose time is before `ms` (transfers carry blocks, not times). Bracketed by the indexed
+ * swaps on either side of `ms` (checked against the node), then searched on block timestamps (lastBlockBetween: a
+ * few reads, not one per halving). Cached, as the past does not move. Throws when the node does not answer (the run
+ * is skipped and tried again).
+ */
+async function lastBlockBefore(read: Db, chain: ChainKey, ms: number): Promise {
+ const k = `${chain}:${ms}`;
+ const hit = lastBlockCache.get(k);
+ if (hit !== undefined) return hit;
+ const at = new Date(ms).toISOString();
+ const cid = chainIdOf(chain);
+ const client = publicClient(chain);
+ const timeOf = async (b: bigint) => Number((await client.getBlock({ blockNumber: b })).timestamp) * 1000;
+ const keep = (b: bigint) => {
+ if (lastBlockCache.size > 2_000) lastBlockCache.clear();
+ lastBlockCache.set(k, b);
+ return b;
+ };
+ const [r] = await read<{ lo: bigint | null; hi: bigint | null }[]>`
+ SELECT (SELECT max(block_number) FROM bb_launch_swaps WHERE chain_id = ${cid} AND block_time < ${at}) AS lo,
+ (SELECT min(block_number) FROM bb_launch_swaps WHERE chain_id = ${cid} AND block_time >= ${at}) AS hi`;
+ // lo: a block before `ms` (the launchpad's deploy block when no swap says better)
+ const deploy = launchDeployBlock(chain);
+ let lo = r?.lo != null ? BigInt(r.lo) : deploy;
+ let tLo = await timeOf(lo);
+ if (tLo >= ms && lo !== deploy) [lo, tLo] = [deploy, await timeOf(deploy)];
+ if (tLo >= ms) return keep(deploy > 0n ? deploy - 1n : 0n); // nothing of the launchpad is before `ms`
+ // hi: a block at or after `ms` (the head when no swap says better)
+ let hi = r?.hi != null ? BigInt(r.hi) : await client.getBlockNumber();
+ let tHi = await timeOf(hi);
+ if (tHi < ms) {
+ const head = await client.getBlockNumber();
+ if (head !== hi) [hi, tHi] = [head, await timeOf(head)];
+ if (tHi < ms) return hi; // the chain has not reached `ms` yet: its head, not cached (it moves)
+ }
+ return keep(await lastBlockBetween(lo, tLo, hi, tHi, ms, timeOf));
+}
+
/** `read` runs the season's reads one after another (a reserved connection); `write` (the pool) runs the replace. */
export async function computeSeason(read: Db, write: Db, season: Season): Promise {
const t0 = Date.now();
const start = new Date(season.starts_at).getTime();
const end = new Date(season.ends_at).getTime();
const now = Date.now();
- const until = new Date(Math.min(now, end)).toISOString();
+ const dataUntil = Math.min(now, end);
+ const until = new Date(dataUntil).toISOString();
+ // how far this run can vouch for the data: the clock, and every indexer's progress. Only a run covering the end
+ // makes the final standings (the hourly and admin recomputes stop after that)
+ const indexed = await indexedThrough(read);
+ const covered = indexed === null ? Math.min(dataUntil, end - 1) : Math.min(dataUntil, indexed);
+ const finalRun = covered >= end;
+
+ // per chain: the season's first block (a creator's in-season outflows), and once it has ended its last one, so
+ // transfers after the end (blocks, not times) are never read as the season's
+ const chains = indexedChains();
+ const seasonStartBlocks = new Map();
+ const endBlock = new Map();
+ for (const c of chains) {
+ try {
+ seasonStartBlocks.set(c, Number((await lastBlockBefore(read, c, start)) + 1n));
+ endBlock.set(chainIdOf(c), dataUntil < now ? await lastBlockBefore(read, c, end) : 2n ** 62n);
+ } catch (err) {
+ console.warn(`[points] ${c} block range:`, err instanceof Error ? err.message.split("\n")[0] : err);
+ return { skipped: `the ${c} node did not answer for the season's block range` };
+ }
+ }
+ const boundCids = [...endBlock.keys()];
+ const boundBlocks = boundCids.map((c) => endBlock.get(c)!.toString());
- // prices first: a run without a complete price picture writes nothing (the last board stays)
+ // prices first: without ETH nothing is priced (the last board stays). One snapshot for the whole run: a page
+ // refresh changing a price in the middle of the reads must not change the run
const usd = await ethUsd();
if (usd === null) return { skipped: "no ETH price" };
const price = await quotePricer(usd);
@@ -82,7 +180,9 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis
const toks = [...scope.values()].map((x) => x.token);
// ETH, GITLAWB and TWIG price most of the board: without them the run would be wrong everywhere, so it writes
- // nothing. Any other quote without a price (a stock, MUSEWORLD) only takes its own tokens out of this run.
+ // nothing. Another quote that normally has a price but has none now (MUSEWORLD, a stock) takes its own tokens out
+ // of an hourly run (the next run puts them back), but never out of the final standings: that run waits instead.
+ // An unlisted pair has no price to begin with: never scored.
const MAIN_QUOTES = new Set(["eth", "gitlawb", "twig"]);
let dropped = 0;
const launchRows = await read`
@@ -93,6 +193,7 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis
const chain = chainKeyOf(l.chain_id) as ChainKey;
const q = price(chain, l.quote);
if (q.usd === null && MAIN_QUOTES.has(q.key)) return { skipped: `no price for ${q.key}` };
+ if (q.usd === null && q.key !== "other" && finalRun) return { skipped: `no price for ${q.key} right now; the final standings wait for it` };
if (q.usd === null) {
dropped++;
continue;
@@ -114,29 +215,31 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis
const firstRows = await read<{ chain_id: number; token: string; trader: string; block_number: bigint; log_index: number; tx_hash: string; t: string; amount0: string; amount1: string }[]>`
SELECT DISTINCT ON (s.chain_id, s.token, s.trader) s.chain_id, s.token, s.trader, s.block_number, s.log_index, s.tx_hash, s.block_time AS t, s.amount0, s.amount1
FROM bb_launch_swaps s JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON s.chain_id = u.cid AND s.token = u.tok
- WHERE s.is_buy AND s.trader IS NOT NULL ORDER BY s.chain_id, s.token, s.trader, s.block_number, s.log_index`;
+ WHERE s.is_buy AND s.trader IS NOT NULL AND s.block_time < ${until} ORDER BY s.chain_id, s.token, s.trader, s.block_number, s.log_index`; // buys after the end are not the season's
// every balance change of every buyer (and launcher) of the tokens in scope: holding is judged on the lowest balance
// since a buy, so tokens topped up later by transfer, or $5 passed from wallet to wallet, never count
const pairs = new Map();
for (const f of firstRows) pairs.set(`${f.chain_id}|${f.token}|${f.trader}`, { cid: f.chain_id, tok: f.token, w: f.trader });
for (const l of launchRows) pairs.set(`${l.chain_id}|${l.token}|${l.launcher}`, { cid: l.chain_id, tok: l.token, w: l.launcher });
const pv = [...pairs.values()];
- const moveRows = await read<{ chain_id: number; token: string; wallet: string; block_number: bigint; log_index: number; tx_hash: string; delta: string }[]>`
- WITH p AS (SELECT * FROM unnest(${pv.map((x) => x.cid)}::int[], ${pv.map((x) => x.tok)}::text[], ${pv.map((x) => x.w)}::text[]) AS p(cid, tok, w))
- SELECT t.chain_id, t.token, p.w AS wallet, t.block_number, t.log_index, t.tx_hash, t.value::text AS delta
- FROM bb_token_transfers t JOIN p ON t.chain_id = p.cid AND t.token = p.tok AND t.to_addr = p.w
+ const moveRows = await read<{ chain_id: number; token: string; wallet: string; block_number: bigint; log_index: number; tx_hash: string; delta: string; burn: boolean }[]>`
+ WITH p AS (SELECT * FROM unnest(${pv.map((x) => x.cid)}::int[], ${pv.map((x) => x.tok)}::text[], ${pv.map((x) => x.w)}::text[]) AS p(cid, tok, w)),
+ e AS (SELECT * FROM unnest(${boundCids}::int[], ${boundBlocks}::bigint[]) AS e(cid, eb))
+ SELECT t.chain_id, t.token, p.w AS wallet, t.block_number, t.log_index, t.tx_hash, t.value::text AS delta, false AS burn
+ FROM bb_token_transfers t JOIN p ON t.chain_id = p.cid AND t.token = p.tok AND t.to_addr = p.w JOIN e ON e.cid = t.chain_id AND t.block_number <= e.eb
UNION ALL
- SELECT t.chain_id, t.token, p.w AS wallet, t.block_number, t.log_index, t.tx_hash, (-t.value)::text AS delta
- FROM bb_token_transfers t JOIN p ON t.chain_id = p.cid AND t.token = p.tok AND t.from_addr = p.w`;
+ SELECT t.chain_id, t.token, p.w AS wallet, t.block_number, t.log_index, t.tx_hash, (-t.value)::text AS delta, t.to_addr IN (${ZERO_ADDR}, ${DEAD_ADDR}) AS burn
+ FROM bb_token_transfers t JOIN p ON t.chain_id = p.cid AND t.token = p.tok AND t.from_addr = p.w JOIN e ON e.cid = t.chain_id AND t.block_number <= e.eb`; // holdings as the season ended
const launcherBuyRows = await read<{ chain_id: number; token: string; tx_hash: string; amount1: string }[]>`
SELECT s.chain_id, s.token, s.tx_hash, s.amount1
FROM bb_launch_swaps s JOIN bb_launches l ON l.chain_id = s.chain_id AND l.token = s.token
JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON s.chain_id = u.cid AND s.token = u.tok
- WHERE s.is_buy AND s.trader = l.launcher`;
+ WHERE s.is_buy AND s.trader = l.launcher AND s.block_time < ${until}`;
const linkedRows = await read<{ chain_id: number; token: string; to_addr: string }[]>`
SELECT DISTINCT t.chain_id, t.token, t.to_addr
FROM bb_token_transfers t JOIN bb_launches l ON l.chain_id = t.chain_id AND l.token = t.token
JOIN unnest(${cids}::int[], ${toks}::text[]) AS u(cid, tok) ON t.chain_id = u.cid AND t.token = u.tok
+ JOIN unnest(${boundCids}::int[], ${boundBlocks}::bigint[]) AS e(cid, eb) ON e.cid = t.chain_id AND t.block_number <= e.eb
WHERE t.from_addr = l.launcher OR t.from_addr IN (SELECT lower(r->>'payout') FROM jsonb_array_elements(l.recipients) r)`;
// every profile, deleted ones too: a flag must survive deleting the profile
const profileRows = await read<{ wallet: string; x_status: string; x_account_created: string | null; x_followers: number | null; points_flag: string | null; hidden: boolean; deleted_at: string | null }[]>`
@@ -176,12 +279,13 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis
launches,
swaps,
firstBuys,
- moves: moveRows.map((m) => ({ key: keyOf(m.chain_id, m.token), wallet: m.wallet, block: Number(m.block_number), logIndex: Number(m.log_index), tx: m.tx_hash, delta: m.delta.startsWith("-") ? -int(m.delta) : int(m.delta) })),
+ moves: moveRows.map((m) => ({ key: keyOf(m.chain_id, m.token), wallet: m.wallet, block: Number(m.block_number), logIndex: Number(m.log_index), tx: m.tx_hash, delta: m.delta.startsWith("-") ? -int(m.delta) : int(m.delta), burn: m.burn })),
launcherBuys,
linked,
system,
eligible,
flagged,
+ seasonStartBlocks,
});
const scores = [...result.wallets.values()];
@@ -217,7 +321,8 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis
${part.map((r) => JSON.stringify(r.breakdown))}::text[])
AS u(w, c, s, tot, e, rc, rs, b)`; // booleans and nullable ints travel as text: the driver does not type those arrays
}
- await t`UPDATE bb_seasons SET computed_at = now(), computed_until = ${until} WHERE id = ${season.id}`;
+ // how far this run vouches for: final only once every indexer has read past the end
+ await t`UPDATE bb_seasons SET computed_at = now(), computed_until = ${new Date(covered).toISOString()} WHERE id = ${season.id}`;
return true;
});
if (!wrote) return { skipped: "the season changed while it was being computed (the next run uses the new window)" };
@@ -253,7 +358,8 @@ async function computeLocked(season: Season): Promise {
/**
* The hourly run, from the sync loop: at most hourly per season (bb_seasons.computed_at, so a season with no points
* yet does not recompute every few minutes), one machine at a time, never while a run is in progress on this machine.
- * An ended season gets exactly one compute after its end, which becomes its final standings.
+ * An ended season is computed every few minutes until a run covers its end (every indexer past it, every price in);
+ * that run's standings are final, and nothing recomputes the season after it.
*/
export async function computePointsIfDue(): Promise {
if (running || Date.now() - lastCheck < 5 * 60_000) return null;
@@ -261,7 +367,7 @@ export async function computePointsIfDue(): Promise {
running = true;
try {
const season = await currentSeason();
- if (!season || isFinal(season)) return null;
+ if (!season || seasonFinal(season)) return null;
if (season.computed_at && Date.now() - new Date(season.computed_at).getTime() < EVERY_MS && !seasonEnded(season)) return null;
return await computeLocked(season);
} finally {
@@ -273,7 +379,7 @@ export async function computePointsIfDue(): Promise {
export async function recomputeNow(): Promise {
const season = await currentSeason();
if (!season) return { error: "no season" };
- if (isFinal(season)) return { error: `${season.name} has ended; its final standings stay as they are` };
+ if (seasonFinal(season)) return { error: `${season.name} has ended; its final standings stay as they are` };
return computeLocked(season);
}
@@ -303,34 +409,51 @@ export async function walletPoints(seasonId: number, wallet: string): Promise {
+/**
+ * What stands between a wallet and the board right now (null = nothing it can do: it is eligible, as far as anyone
+ * may be told). Being kept off points is a moderator's call that is never told: the reason is worked out as if it
+ * were not there, so a kept-off wallet looks exactly like any other wallet in the same state. (A hidden profile is
+ * public already, so "hidden" is told.)
+ */
+export async function walletReason(wallet: string): Promise {
const db = maybeDb();
if (!db || !isAddress(wallet)) return "no_profile";
const [p] = await db<{ x_status: string; x_account_created: string | null; x_followers: number | null; points_flag: string | null; hidden: boolean; deleted_at: string | null }[]>`
SELECT x_status, x_account_created, x_followers, points_flag, hidden, deleted_at FROM bb_profiles WHERE wallet = ${wallet.toLowerCase()}`;
- return notEligibleReason(p ?? null, Date.now());
+ // (a kept-off wallet that is otherwise eligible reads null, as an eligible one does until the next hourly run)
+ return notEligibleReason(p ? { ...p, points_flag: null } : null, Date.now()) as PublicReason | null;
}
// ── admin ────────────────────────────────────────────────────────────────────
-export async function startSeason(days = 28): Promise {
+/**
+ * Start a season (hidden: a shadow run until it is published), or null when one is already running. Under a lock,
+ * so two starts at once make one season; the slug and the number come from the row's own id, so a deleted season
+ * can never make the next start collide with an existing slug.
+ */
+export async function startSeason(days = 28): Promise {
const db = maybeDb()!;
- const [{ n }] = await db<{ n: number }[]>`SELECT count(*)::int AS n FROM bb_seasons`;
- const [s] = await db`
- INSERT INTO bb_seasons (slug, name, starts_at, ends_at, public) VALUES (${`s${n + 1}`}, ${`Season ${n + 1}`}, now(), now() + make_interval(days => ${days}), false)
- RETURNING id, slug, name, starts_at, ends_at, public, computed_at, computed_until, published_at`;
- return s;
+ return db.begin(async (tx) => {
+ const t = tx as unknown as Db;
+ await t`SELECT pg_advisory_xact_lock(hashtext('points-season-start'))`;
+ // any season not over blocks (now() is this transaction's start, from before the lock: a start that went
+ // through meanwhile can begin a moment after it, so its start time is not compared)
+ const [running] = await t`SELECT 1 FROM bb_seasons WHERE ends_at > now() LIMIT 1`;
+ if (running) return null;
+ const [row] = await t<{ id: number }[]>`
+ INSERT INTO bb_seasons (slug, name, starts_at, ends_at, public) VALUES (${`pending-${Date.now()}-${Math.random().toString(36).slice(2)}`}, '', now(), now() + make_interval(days => ${days}), false) RETURNING id`;
+ const [s] = await t`
+ UPDATE bb_seasons SET slug = 's' || id, name = 'Season ' || (SELECT count(*) FROM bb_seasons WHERE id <= ${row.id}) WHERE id = ${row.id}
+ RETURNING id, slug, name, starts_at, ends_at, public, computed_at, computed_until, published_at`;
+ return s;
+ }) as Promise;
}
/**
- * The FIRST publish starts the season for everyone: its clock restarts now with the same length and the shadow run's
- * points are dropped (they were for tuning). Any later publish (after hiding the boards, or of an ended season) only
- * shows the boards again: the clock and the points stay as they are.
- */
/**
* Make a season public. The first publish starts its clock now and drops the shadow run's rows ("started"); showing a
* season again after a hide changes neither ("shown"). A season that never went public and has ended is "refused":
- * its shadow boards were never told to anyone, and they never become public.
+ * its shadow boards were never told to anyone, and they never become public. (The first publish restarts the clock
+ * with the same length: the shadow run was for tuning.)
*/
export async function publishSeason(id: number): Promise<"started" | "shown" | "refused"> {
const db = maybeDb()!;
@@ -363,11 +486,29 @@ export async function endSeasonNow(id: number): Promise {
/** The final standings of an ended season, before another one starts (so starting never skips them). True = final. */
export async function finalizeIfEnded(season: Season): Promise {
if (!seasonEnded(season)) return false;
- if (isFinal(season)) return true;
+ if (seasonFinal(season)) return true;
await computeLocked(season);
const db = maybeDb()!;
const [s] = await db`SELECT id, slug, name, starts_at, ends_at, public, computed_at, computed_until, published_at FROM bb_seasons WHERE id = ${season.id}`;
- return Boolean(s && isFinal(s));
+ return Boolean(s && seasonFinal(s));
+}
+
+/** One season by id, or null. */
+export async function seasonById(id: number): Promise {
+ const db = maybeDb();
+ if (!db) return null;
+ const [s] = await db`SELECT id, slug, name, starts_at, ends_at, public, computed_at, computed_until, published_at FROM bb_seasons WHERE id = ${id}`;
+ return s ?? null;
+}
+
+/** The latest season that went public other than `exceptId` (a public Season 1 while Season 2 runs hidden), or null. */
+export async function previousPublishedSeason(exceptId: number): Promise {
+ const db = maybeDb();
+ if (!db) return null;
+ const [s] = await db`
+ SELECT id, slug, name, starts_at, ends_at, public, computed_at, computed_until, published_at FROM bb_seasons
+ WHERE published_at IS NOT NULL AND id <> ${exceptId} ORDER BY starts_at DESC LIMIT 1`;
+ return s ?? null;
}
/** The shadow view for admins: top wallets on each board, eligible or not, with why lines (the trial-week review). */
From 75c2b4524b9609a0b8ced10c458daf8fcc96d976 Mon Sep 17 00:00:00 2001
From: Kevin Codex
Date: Fri, 9 Oct 2026 16:01:02 +0800
Subject: [PATCH 11/13] fix(points): delta review on #85 (final waits for
attribution, no recompute while waiting, best 3 by added value, season names)
- The final count waits while any swap of the season is still being attributed (unchecked, or an EntryPoint receipt
not read yet: the buy sits on the bundler meanwhile and would be lost for good), at most an hour past the end.
- Once a season is over, a run that cannot be final (an indexer behind, swaps unattributed) returns before any read
instead of redoing the whole compute every 5 minutes for the same board.
- The final run's wait for a missing stock / MUSEWORLD price is bounded to a day (a feed gone for good then drops
its tokens, logged), so it can never hold the standings back forever.
- Each launch day's best 3 tokens are picked one at a time by what each adds, fees counted against the per-trader
cap the tokens already picked used: three tokens sharing one trader's cap are worth one cap, not three.
- A new season's number follows the highest season name, so a deleted season never makes two "Season 2"s.
- The start-blocked message says what the final standings wait for.
Tests: scorer (shared trader cap across same-day tokens; fails on the previous scorer); DB test points-fixes.mts
now 23 checks (attribution wait and its one-hour bound, no recompute while waiting, season name), 23/23 here, and
the previous commit fails 4 of them.
---
app/src/app/api/points/admin/route.ts | 2 +-
app/src/lib/points/score.test.ts | 13 ++++++++
app/src/lib/points/score.ts | 28 +++++++++++++----
app/src/lib/points/server.ts | 45 ++++++++++++++++++++-------
4 files changed, 70 insertions(+), 18 deletions(-)
diff --git a/app/src/app/api/points/admin/route.ts b/app/src/app/api/points/admin/route.ts
index 80359223..819ea175 100644
--- a/app/src/app/api/points/admin/route.ts
+++ b/app/src/app/api/points/admin/route.ts
@@ -50,7 +50,7 @@ export async function POST(req: Request) {
if (season && !seasonEnded(season)) return NextResponse.json({ error: `${season.name} is still running` }, { status: 409 });
// a season that went public keeps its final standings, so they are computed first; a hidden run that was
// discarded has none to keep, and never blocks the next season (not even when a price is missing)
- if (season && season.published_at && !(await finalizeIfEnded(season))) return NextResponse.json({ error: `${season.name}'s final standings could not be computed yet; try again in a minute` }, { status: 409 });
+ if (season && season.published_at && !(await finalizeIfEnded(season))) return NextResponse.json({ error: `${season.name}'s final standings are not in yet (they wait for every indexer, its last swaps and every price, at most about an hour past the end); try again in a few minutes` }, { status: 409 });
const s = await startSeason(days);
if (!s) return NextResponse.json({ error: "a season is already running" }, { status: 409 });
void recomputeNow().catch(() => {});
diff --git a/app/src/lib/points/score.test.ts b/app/src/lib/points/score.test.ts
index b470e6fd..de6f2e26 100644
--- a/app/src/lib/points/score.test.ts
+++ b/app/src/lib/points/score.test.ts
@@ -305,3 +305,16 @@ test("a buy whose tokens the wallet never received (a relayer or bundler the swa
assert.equal(r.wallets.get("0xrelay0")?.scoutWhy.early ?? 0, 0, "the relayer takes no early slot");
assert.equal(r.wallets.get(w(1))!.scoutWhy.early, 1, "the first real buyers keep theirs");
});
+
+test("best 3 by what each token adds: three tokens sharing one trader's fee cap are worth one cap, not three", () => {
+ const launches = ["0xa", "0xb", "0xc", "0xd"].map((t, i) => ({ ...L, key: `base:${t}`, launchTime: START - DAY + i }));
+ // one eligible trader buys $1,000 of each of 0xa..0xc on the same day ($10 fee each: 100 points uncapped, 50 capped,
+ // and 50 for the three together); 0xd has one eligible holder (45 after a week)
+ const whale = launches.slice(0, 3).map((l) => buyer(w(900), START + DAY, 0.5, 1, l));
+ const fan = buyer(w(901), START + DAY, 0.003, 1, launches[3]);
+ const r = scoreSeason(build([...whale, fan], { launches, eligible: new Set([w(900), w(901)]) }));
+ const c = r.wallets.get(CREATOR)!;
+ // the whale counts once as a holder (45) and once for fees (50); 0xd's fan adds 45 more
+ assert.equal(c.creatorWhy.verifiedHolders, 2, "0xd's holder is counted: 0xd was not cut for a second share of the same cap");
+ assert.ok(c.creator >= 45 + 50 + 45, `creator ${c.creator}`);
+});
diff --git a/app/src/lib/points/score.ts b/app/src/lib/points/score.ts
index ad25a7d5..12f2cfc3 100644
--- a/app/src/lib/points/score.ts
+++ b/app/src/lib/points/score.ts
@@ -308,20 +308,36 @@ export function scoreSeason(input: ScoreInput): ScoreResult {
}
return { pts, usd };
};
- // the best 3 tokens of each launch day, ranked by what each would actually be worth: its holders (each on their
- // best token) and its fees with the per-trader-per-day cap applied (an uncapped estimate let one big fee payer
- // push a token with real holders out of the 3)
+ // the best 3 tokens of each launch day, picked one at a time by what each ADDS: its holders (each on their best
+ // token) and its fees with the per-trader-per-day cap applied after what the tokens already picked used of it (an
+ // uncapped estimate let one big fee payer push out a token with real holders; three tokens sharing one trader's
+ // cap are worth one cap, not three)
const firstPass = new Map();
for (const [, h] of assign(live)) firstPass.set(h.t, (firstPass.get(h.t) ?? 0) + h.pts);
- const estimate = new Map(live.map((t) => [t, (firstPass.get(t) ?? 0) + feePoints(t, new Map()).pts]));
const byDay = new Map();
for (const t of live) {
const d = utcDay(t.l.launchTime);
byDay.set(d, [...(byDay.get(d) ?? []), t]);
}
const kept: TokenCreator[] = [];
- for (const list of byDay.values()) kept.push(...[...list].sort((a, b) => (estimate.get(b) ?? 0) - (estimate.get(a) ?? 0)).slice(0, RULES.bestTokensPerDay));
- kept.sort((a, b) => (estimate.get(b) ?? 0) - (estimate.get(a) ?? 0) || a.l.key.localeCompare(b.l.key));
+ const added = new Map();
+ for (const list of byDay.values()) {
+ const left = [...list].sort((a, b) => a.l.key.localeCompare(b.l.key));
+ const used = new Map();
+ for (let picked = 0; picked < RULES.bestTokensPerDay && left.length; picked++) {
+ let best = 0;
+ let bestValue = -1;
+ left.forEach((t, i) => {
+ const v = (firstPass.get(t) ?? 0) + feePoints(t, new Map(used)).pts;
+ if (v > bestValue) [best, bestValue] = [i, v];
+ });
+ const [t] = left.splice(best, 1);
+ feePoints(t, used);
+ added.set(t, bestValue);
+ kept.push(t);
+ }
+ }
+ kept.sort((a, b) => (added.get(b) ?? 0) - (added.get(a) ?? 0) || a.l.key.localeCompare(b.l.key));
// the count: each holder on their best kept token (a holder whose favourite was cut still counts on another), and
// fees with the cap shared across all of this creator's kept tokens
const holderBest = assign(kept);
diff --git a/app/src/lib/points/server.ts b/app/src/lib/points/server.ts
index 836df231..abc3a574 100644
--- a/app/src/lib/points/server.ts
+++ b/app/src/lib/points/server.ts
@@ -22,12 +22,14 @@ import { isEligible, notEligibleReason, rankBy, scoreSeason, whyLine, type Creat
*
* Prices: without ETH, GITLAWB or TWIG a run writes nothing (the last board stays). Another quote that normally has a
* price (MUSEWORLD, a stock) but has none right now only takes its own tokens out of an hourly run, which the next run
- * puts back; a run that would fix the FINAL standings writes nothing instead, so a price blip can never drop tokens
- * from them for good. Tokens quoted in an unlisted pair never count (they have no price to hold anything to).
+ * puts back; a run that would fix the FINAL standings writes nothing instead (for up to a day), so a price blip can
+ * never drop tokens from them for good. Tokens quoted in an unlisted pair never count (they have no price to hold
+ * anything to).
*
* Final standings are fixed only by a compute that read everything through the season's end: the clock passing the
* end is not enough, every indexer must have read past it too (computed_until is capped at the least of their
- * cursors), and every read stops at the season's last block, so a late final compute sees the season as it ended.
+ * cursors), the season's swaps must be attributed (at most an hour's wait), and every read stops at the season's last
+ * block, so a late final compute sees the season as it ended. Until then an ended season is not recomputed at all.
*/
export type Season = { id: number; slug: string; name: string; starts_at: string; ends_at: string; public: boolean; computed_at: string | null; computed_until: string | null; published_at: string | null };
@@ -132,6 +134,17 @@ async function lastBlockBefore(read: Db, chain: ChainKey, ms: number): Promise {
+ const [r] = await read<{ open: boolean }[]>`
+ SELECT EXISTS (SELECT 1 FROM bb_launch_swaps WHERE (trader_via IS NULL OR trader_via = 'receipt_pending') AND block_time >= ${from} AND block_time < ${to}) AS open`;
+ return Boolean(r?.open);
+}
+
/** `read` runs the season's reads one after another (a reserved connection); `write` (the pool) runs the replace. */
export async function computeSeason(read: Db, write: Db, season: Season): Promise {
const t0 = Date.now();
@@ -144,7 +157,13 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis
// makes the final standings (the hourly and admin recomputes stop after that)
const indexed = await indexedThrough(read);
const covered = indexed === null ? Math.min(dataUntil, end - 1) : Math.min(dataUntil, indexed);
- const finalRun = covered >= end;
+ // a smart-wallet buy is credited to the bundler until its receipt is read (then it is no real buy): the final count
+ // waits for the season's swaps still being attributed, at most an hour past the end (a receipt that never comes is
+ // settled on its sender by then anyway)
+ const unsettled = covered >= end && now < end + SETTLE_WAIT_MS && (await unattributedIn(read, season.starts_at, season.ends_at));
+ const finalRun = covered >= end && !unsettled;
+ // once the season is over, a run that cannot be final would only redo the whole compute for the same board: wait
+ if (now >= end && !finalRun) return { skipped: unsettled ? "waiting for the season's last swaps to be attributed" : "waiting for every indexer to read past the season's end" };
// per chain: the season's first block (a creator's in-season outflows), and once it has ended its last one, so
// transfers after the end (blocks, not times) are never read as the season's
@@ -181,7 +200,8 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis
// ETH, GITLAWB and TWIG price most of the board: without them the run would be wrong everywhere, so it writes
// nothing. Another quote that normally has a price but has none now (MUSEWORLD, a stock) takes its own tokens out
- // of an hourly run (the next run puts them back), but never out of the final standings: that run waits instead.
+ // of an hourly run (the next run puts them back); the final run waits for it instead (up to a day: a feed gone for
+ // good then drops its tokens, logged).
// An unlisted pair has no price to begin with: never scored.
const MAIN_QUOTES = new Set(["eth", "gitlawb", "twig"]);
let dropped = 0;
@@ -193,7 +213,7 @@ export async function computeSeason(read: Db, write: Db, season: Season): Promis
const chain = chainKeyOf(l.chain_id) as ChainKey;
const q = price(chain, l.quote);
if (q.usd === null && MAIN_QUOTES.has(q.key)) return { skipped: `no price for ${q.key}` };
- if (q.usd === null && q.key !== "other" && finalRun) return { skipped: `no price for ${q.key} right now; the final standings wait for it` };
+ if (q.usd === null && q.key !== "other" && finalRun && now < end + PRICE_WAIT_MS) return { skipped: `no price for ${q.key} right now; the final standings wait for it` };
if (q.usd === null) {
dropped++;
continue;
@@ -358,8 +378,9 @@ async function computeLocked(season: Season): Promise {
/**
* The hourly run, from the sync loop: at most hourly per season (bb_seasons.computed_at, so a season with no points
* yet does not recompute every few minutes), one machine at a time, never while a run is in progress on this machine.
- * An ended season is computed every few minutes until a run covers its end (every indexer past it, every price in);
- * that run's standings are final, and nothing recomputes the season after it.
+ * An ended season is checked every few minutes until a run can cover its end (every indexer past it, every price in,
+ * its last swaps attributed or an hour gone); only that run computes, its standings are final, and nothing recomputes
+ * the season after it.
*/
export async function computePointsIfDue(): Promise {
if (running || Date.now() - lastCheck < 5 * 60_000) return null;
@@ -427,8 +448,8 @@ export async function walletReason(wallet: string): Promise
// ── admin ────────────────────────────────────────────────────────────────────
/**
* Start a season (hidden: a shadow run until it is published), or null when one is already running. Under a lock,
- * so two starts at once make one season; the slug and the number come from the row's own id, so a deleted season
- * can never make the next start collide with an existing slug.
+ * so two starts at once make one season; the slug comes from the row's own id and the number follows the highest
+ * season name, so a deleted season never makes the next start collide with a slug or repeat a name.
*/
export async function startSeason(days = 28): Promise {
const db = maybeDb()!;
@@ -442,7 +463,9 @@ export async function startSeason(days = 28): Promise {
const [row] = await t<{ id: number }[]>`
INSERT INTO bb_seasons (slug, name, starts_at, ends_at, public) VALUES (${`pending-${Date.now()}-${Math.random().toString(36).slice(2)}`}, '', now(), now() + make_interval(days => ${days}), false) RETURNING id`;
const [s] = await t`
- UPDATE bb_seasons SET slug = 's' || id, name = 'Season ' || (SELECT count(*) FROM bb_seasons WHERE id <= ${row.id}) WHERE id = ${row.id}
+ UPDATE bb_seasons SET slug = 's' || id,
+ name = 'Season ' || (1 + COALESCE((SELECT max((regexp_match(name, '^Season ([0-9]+)$'))[1]::int) FROM bb_seasons WHERE id <> ${row.id}), 0))
+ WHERE id = ${row.id}
RETURNING id, slug, name, starts_at, ends_at, public, computed_at, computed_until, published_at`;
return s;
}) as Promise;
From 8f6e143cde16639fcc5f9e532dc2ecce6a92968b Mon Sep 17 00:00:00 2001
From: Kevin Codex
Date: Fri, 9 Oct 2026 21:49:04 +0800
Subject: [PATCH 12/13] fix(points): CodeRabbit on #85 (leaderboard refreshes
itself, no "within the hour" promises)
- /leaderboard re-renders its server-rendered boards every 5 minutes while visible (and on coming back to the tab),
at the same pace as your own standing (usePoints.REFRESH_MS), in place: a page left open across an hourly run no
longer shows a new rank of yours beside an old board.
- The final standings and unlocked points no longer promise "within the hour" (a run can wait on prices, indexers or
attribution): "waiting for the final count", "with the next points update" on the leaderboard, the season card and
the rules page.
- Duplicated JSDoc opener on publishSeason removed.
Test: points-board-refresh.cjs (a row bumped in the DB shows up 5 minutes later without a reload; Playwright clock)
passes on a production build of this commit and fails on one of 75c2b45.
---
app/src/app/leaderboard/page.tsx | 4 ++-
app/src/app/rules/page.tsx | 2 +-
app/src/components/points/BoardRefresh.tsx | 29 ++++++++++++++++++++++
app/src/components/points/SeasonCard.tsx | 4 +--
app/src/components/points/usePoints.ts | 2 +-
app/src/lib/points/server.ts | 1 -
6 files changed, 36 insertions(+), 6 deletions(-)
create mode 100644 app/src/components/points/BoardRefresh.tsx
diff --git a/app/src/app/leaderboard/page.tsx b/app/src/app/leaderboard/page.tsx
index 942374c4..086ce7e7 100644
--- a/app/src/app/leaderboard/page.tsx
+++ b/app/src/app/leaderboard/page.tsx
@@ -4,6 +4,7 @@ import { ArrowRight, Trophy } from "lucide-react";
import shell from "@/components/sections/SectionShell.module.css";
import NamesProvider from "@/components/profile/NamesProvider";
import { WhoAvatar, WhoName } from "@/components/profile/Who";
+import BoardRefresh from "@/components/points/BoardRefresh";
import YourStanding from "@/components/points/YourStanding";
import { memo } from "@/lib/launchpad/memo";
import { boardRows, publicSeason, seasonEnded, seasonFinal, type Board } from "@/lib/points/server";
@@ -62,7 +63,7 @@ export default async function LeaderboardPage({ searchParams }: { searchParams:
{ended ? (
<>
{final ? "Final standings" : "Season over"}
-
ended {new Date(season.ends_at).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })}{final ? "" : " · final standings within the hour"}
+
ended {new Date(season.ends_at).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })}{final ? "" : " · waiting for the final count"}
>
) : (
<>
@@ -76,6 +77,7 @@ export default async function LeaderboardPage({ searchParams }: { searchParams:
+
{data.rows.length === 0 ? (
diff --git a/app/src/app/rules/page.tsx b/app/src/app/rules/page.tsx
index 29b9cc7e..be4d69e2 100644
--- a/app/src/app/rules/page.tsx
+++ b/app/src/app/rules/page.tsx
@@ -121,7 +121,7 @@ export default function RulesPage() {
What counts as a real buyer?
A wallet whose first buy of a token was ${RULES.minTradeUsd} or more, made after the launch block, the next {RULES.sniperBlocks} blocks and the first {RULES.sniperMs / 1000} seconds. The wallet must receive the tokens of that buy itself (a relayer or router that only passed them on is not a buyer). Never the launcher, a fee recipient, a wallet that got the token by transfer from either, or a wallet kept off points. A real holder is a real buyer with ${RULES.holderMinUsd} or more (${RULES.verifiedHolderMinUsd} for a qualifying profile, below) of that first buy still in the wallet.
Holding is judged per buy: whenever tokens leave a wallet (sold, or moved to any other wallet), the most recently received ones go first. So selling a later buy does not touch an earlier one, tokens sent in from another wallet never stand in for a buy, and moving a position out counts the same as selling it. Only tokens priced in a quote we can price count.
How do creators earn?
On any token you launched, only what happens during the season counts. Each real holder who first bought this season and still holds a day later earns you {RULES.verifiedHolder} points if their profile qualifies (below), or {RULES.holder} if not; a week later, half again. Each holder counts once for you, on whichever of your tokens they are worth most.
Fees on buys by traders with a qualifying profile earn {RULES.creatorFeePointsPerUsd} points per $1 for each buy that is still at least half held (a buy sold straight back earns nothing), up to {RULES.creatorFeeCapPerTraderDay} from one trader a day. Of the tokens you launch on one day, only your best {RULES.bestTokensPerDay} count. A token scores nothing if you sold or moved tokens out during the season (to any wallet, including one of your own; burning them is fine) and less than half of your own buys of it is left.
How do traders earn?
Be one of the first {RULES.earlyFirstBuyers} real buyers of a token that now has {RULES.earlyHoldersTarget} real holders, with your first buy this season: {RULES.early} points, and {RULES.earlyStillHolding} more while you still hold half of that first buy.
Every token you first buy this season, with a real first buy, and still half-hold a day later (that buy itself, not a later one; a token you already held before the season does not count): {RULES.hold} points, up to {RULES.holdTokensPerDay} tokens a day. Fees you pay buying other people's tokens with {RULES.scoutFeeMinHolders}+ real holders ({RULES.scoutFeeMinEligibleHolders}+ of them qualifying), for each buy still at least half held: {RULES.scoutFeePointsPerUsd} points per $1, up to {RULES.scoutFeeCapPerDay} a day. Trades under ${RULES.minTradeUsd} earn nothing.
- Who appears on the leaderboard?
Profiles verified with one post on X from an account at least {RULES.eligibleMinAgeDays} days old with {RULES.eligibleMinFollowers} or more followers. Everyone else can see the points waiting for them on their dashboard; while the season runs, they unlock for the whole season so far within the hour after the profile qualifies. The post itself earns nothing.
+ Who appears on the leaderboard?
Profiles verified with one post on X from an account at least {RULES.eligibleMinAgeDays} days old with {RULES.eligibleMinFollowers} or more followers. Everyone else can see the points waiting for them on their dashboard; while the season runs, they unlock for the whole season so far with the next points update after the profile qualifies (points update about every hour). The post itself earns nothing.
diff --git a/app/src/components/points/BoardRefresh.tsx b/app/src/components/points/BoardRefresh.tsx
new file mode 100644
index 00000000..d03b0ce8
--- /dev/null
+++ b/app/src/components/points/BoardRefresh.tsx
@@ -0,0 +1,29 @@
+"use client";
+
+import { startTransition, useEffect } from "react";
+import { useRouter } from "next/navigation";
+import { REFRESH_MS } from "./usePoints";
+
+/**
+ * Re-renders the server-rendered boards every few minutes while the page is visible (and on coming back to it), at
+ * the same pace as your own standing (usePoints), so a page left open across an hourly run never shows a new rank of
+ * yours beside an old board. Client state (your standing, the tabs) is kept.
+ */
+export default function BoardRefresh() {
+ const router = useRouter();
+ useEffect(() => {
+ let last = Date.now();
+ const tick = () => {
+ if (document.visibilityState !== "visible" || Date.now() - last < REFRESH_MS) return;
+ last = Date.now();
+ startTransition(() => router.refresh());
+ };
+ const id = window.setInterval(tick, 30_000);
+ document.addEventListener("visibilitychange", tick);
+ return () => {
+ window.clearInterval(id);
+ document.removeEventListener("visibilitychange", tick);
+ };
+ }, [router]);
+ return null;
+}
diff --git a/app/src/components/points/SeasonCard.tsx b/app/src/components/points/SeasonCard.tsx
index a0802a43..95eec78d 100644
--- a/app/src/components/points/SeasonCard.tsx
+++ b/app/src/components/points/SeasonCard.tsx
@@ -35,14 +35,14 @@ export default function SeasonCard({ address }: { address: string }) {
return (
{p.season.name} · {p.season.final ? "final standings" : p.season.ended ? "season over, waiting for the final count" : `${days} ${days === 1 ? "day" : "days"} left`}
Leaderboard
{me ? (
<>
{waiting ? (
- {me.total.toLocaleString("en-US")} points are waiting for you. {NEXT_STEP[step]}, and they unlock for the whole season so far within the hour.
+ {me.total.toLocaleString("en-US")} points are waiting for you. {NEXT_STEP[step]}, and they unlock for the whole season so far with the next points update.
: null}
diff --git a/app/src/components/points/usePoints.ts b/app/src/components/points/usePoints.ts
index 8a67ae91..05011733 100644
--- a/app/src/components/points/usePoints.ts
+++ b/app/src/components/points/usePoints.ts
@@ -6,7 +6,7 @@ import { PUBLIC_REASONS, type PublicReason } from "@/lib/points/score";
export type PointsState = { season: { name: string; starts_at: string; ends_at: string; ended: boolean; final: boolean } | null; me: WalletPoints | null; reason: PublicReason | null; at: number } | null;
-const REFRESH_MS = 5 * 60_000; // points are recomputed hourly; a page left open picks up a publish or a new run
+export const REFRESH_MS = 5 * 60_000; // points are recomputed hourly; a page left open picks up a publish or a new run
const RETRY_MS = [10_000, 30_000, 60_000]; // after a failed read, then back to the normal refresh
/**
diff --git a/app/src/lib/points/server.ts b/app/src/lib/points/server.ts
index abc3a574..ae4a9871 100644
--- a/app/src/lib/points/server.ts
+++ b/app/src/lib/points/server.ts
@@ -471,7 +471,6 @@ export async function startSeason(days = 28): Promise {
}) as Promise;
}
-/**
/**
* Make a season public. The first publish starts its clock now and drops the shadow run's rows ("started"); showing a
* season again after a hide changes neither ("shown"). A season that never went public and has ended is "refused":
From eb5ab387743c0b3093fad16c5e59f90725d79a1e Mon Sep 17 00:00:00 2001
From: Kevin Codex
Date: Fri, 9 Oct 2026 23:55:39 +0800
Subject: [PATCH 13/13] fix(points): CodeRabbit on #85 (teaser refreshes,
qualified unlock copy, start message, no publish for a discarded run)
- The /leaderboard teaser mounts BoardRefresh too: a teaser left open shows the boards once the season is published.
- The teaser no longer promises that a verified profile alone unlocks points: it names the X account age and
follower minimums (RULES).
- The admin "start" refusal states the two waits apart: indexers and the season's last swaps (at most about an hour
past the end), a missing price (up to a day).
- The admin panel offers no publish / "Show boards" for a season that ended without going public (a discarded hidden
run): the server always refuses it.
Tests: points-teaser-refresh.cjs (production build: passes here, fails without the teaser's BoardRefresh);
points-admin-prev.cjs discarded-run check (fails on the previous PointsAdmin).
---
app/src/app/api/points/admin/route.ts | 2 +-
app/src/app/leaderboard/page.tsx | 5 ++++-
app/src/components/points/PointsAdmin.tsx | 3 ++-
3 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/app/src/app/api/points/admin/route.ts b/app/src/app/api/points/admin/route.ts
index 819ea175..f80414a1 100644
--- a/app/src/app/api/points/admin/route.ts
+++ b/app/src/app/api/points/admin/route.ts
@@ -50,7 +50,7 @@ export async function POST(req: Request) {
if (season && !seasonEnded(season)) return NextResponse.json({ error: `${season.name} is still running` }, { status: 409 });
// a season that went public keeps its final standings, so they are computed first; a hidden run that was
// discarded has none to keep, and never blocks the next season (not even when a price is missing)
- if (season && season.published_at && !(await finalizeIfEnded(season))) return NextResponse.json({ error: `${season.name}'s final standings are not in yet (they wait for every indexer, its last swaps and every price, at most about an hour past the end); try again in a few minutes` }, { status: 409 });
+ if (season && season.published_at && !(await finalizeIfEnded(season))) return NextResponse.json({ error: `${season.name}'s final standings are not in yet: they wait for every indexer and the season's last swaps (at most about an hour past the end) and for a missing price (up to a day); try again in a few minutes` }, { status: 409 });
const s = await startSeason(days);
if (!s) return NextResponse.json({ error: "a season is already running" }, { status: 409 });
void recomputeNow().catch(() => {});
diff --git a/app/src/app/leaderboard/page.tsx b/app/src/app/leaderboard/page.tsx
index 086ce7e7..5341c92d 100644
--- a/app/src/app/leaderboard/page.tsx
+++ b/app/src/app/leaderboard/page.tsx
@@ -7,6 +7,7 @@ import { WhoAvatar, WhoName } from "@/components/profile/Who";
import BoardRefresh from "@/components/points/BoardRefresh";
import YourStanding from "@/components/points/YourStanding";
import { memo } from "@/lib/launchpad/memo";
+import { RULES } from "@/lib/points/score";
import { boardRows, publicSeason, seasonEnded, seasonFinal, type Board } from "@/lib/points/server";
import { pageMetadata } from "@/lib/seo";
@@ -30,12 +31,14 @@ export default async function LeaderboardPage({ searchParams }: { searchParams:
Season points
Season 1 is almost here.
-
Points for creators whose tokens find real buyers, and for traders who find them early. Computed from the chain every hour. Create your profile and verify it with one post on X now, and your points unlock from day one.
+
Points for creators whose tokens find real buyers, and for traders who find them early. Computed from the chain every hour. Create your profile now and verify it with one post on X, from an X account at least {RULES.eligibleMinAgeDays} days old with {RULES.eligibleMinFollowers} or more followers, and your points count from day one.
Create your profile
How points work
+ {/* a teaser left open shows the boards once the season is published */}
+
);
}
diff --git a/app/src/components/points/PointsAdmin.tsx b/app/src/components/points/PointsAdmin.tsx
index c26e425b..79bfa074 100644
--- a/app/src/components/points/PointsAdmin.tsx
+++ b/app/src/components/points/PointsAdmin.tsx
@@ -132,7 +132,8 @@ export default function PointsAdmin() {
)}
{!running ? : null}
- {s && !s.public ? : null}
+ {/* a season that ended without going public (a discarded hidden run) can never be published */}
+ {s && !s.public && (s.published_at || running) ? : null}
{s && s.public ? : null}
{s ? : null}
{running && !confirmEnd ? : null}