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/db/schema.sql b/app/db/schema.sql
index a48d56ee..9a3e5448 100644
--- a/app/db/schema.sql
+++ b/app/db/schema.sql
@@ -393,3 +393,39 @@ 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. 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…
+ name text NOT NULL,
+ 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);
+-- 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)
+);
+-- 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/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..f80414a1
--- /dev/null
+++ b/app/src/app/api/points/admin/route.ts
@@ -0,0 +1,91 @@
+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 { 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?, 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 });
+ 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 days = normalizeSeasonDays(b.days); // signed for "start": the season is exactly as long as the admin signed
+ 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 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(() => {});
+ return done({ season: s });
+ }
+ case "publish": {
+ 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: `${s.name} ended without being published; start a new season` }, { status: 409 });
+ if (r === "started") void recomputeNow().catch(() => {});
+ 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 "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 done();
+ }
+ case "recompute": {
+ const r = await recomputeNow();
+ if (r && "error" in r) return NextResponse.json({ error: r.error }, { status: 409 });
+ return done({ result: r });
+ }
+ }
+ } 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..8d723df6
--- /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, publicSeason } 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: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) {
+ 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..413318da
--- /dev/null
+++ b/app/src/app/api/points/route.ts
@@ -0,0 +1,31 @@
+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";
+
+/**
+ * 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. 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" } });
+ // 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);
+ 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..5341c92d
--- /dev/null
+++ b/app/src/app/leaderboard/page.tsx
@@ -0,0 +1,107 @@
+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 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";
+
+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";
+ const season = await memo("points:public-season", 10_000, publicSeason);
+
+ if (!season) {
+ 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 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 */}
+
+
+ );
+ }
+
+ 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}
+ );
+ const names = Object.fromEntries(data.rows.map((r) => [r.wallet, data.names[r.wallet] ?? null]));
+
+ return (
+
+
+
+
+
+
{season.name}
+
Leaderboard
+
Real buyers and early finds, read from the chain every hour. Points are reputation: no cash value, not a token. How points work
+
+
+ {ended ? (
+ <>
+
{final ? "Final standings" : "Season over"}
+
ended {new Date(season.ends_at).toLocaleDateString("en-US", { month: "short", day: "numeric", timeZone: "UTC" })}{final ? "" : " · waiting for the final count"}
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..be4d69e2 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,7 @@ 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";
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 +30,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. */
@@ -49,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 (
@@ -112,6 +114,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.
+
+ 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 with the next points update after the profile qualifies (points update about every hour). 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..0bb62a15 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 { publicSeason, walletPoints } from "@/lib/points/server";
+import { Trophy } from "lucide-react";
export const dynamic = "force-dynamic";
@@ -59,6 +61,11 @@ 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
+ // fails soft: a points hiccup never takes the profile page down
+ 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" });
@@ -99,6 +106,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..46e929fe 100644
--- a/app/src/components/launchpad/MeDashboard.tsx
+++ b/app/src/components/launchpad/MeDashboard.tsx
@@ -28,12 +28,14 @@ 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[] };
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}`;
/**
@@ -198,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 (
Refresh failed: {err}. Showing the last loaded data. Try Refresh again.
: null}
} v={String(me.launches.length)} hint="Across all chains" />
@@ -369,6 +375,7 @@ function WalletDashboard({ address, isConnected }: { address: Address | undefine
);
}
+/** One figure in the dashboard's stats row. */
function Stat({ k, icon, v, hint, accent }: { k: string; icon: React.ReactNode; v: string; hint: string; accent?: "up" | "warm" }) {
return (
@@ -379,6 +386,7 @@ function Stat({ k, icon, v, hint, accent }: { k: string; icon: React.ReactNode;
);
}
+/** What a dashboard section shows when it has nothing yet, with the one next step. */
function EmptyState({ icon, title, description, href, action }: { icon: "launches" | "holdings" | "trades"; title: string; description: string; href: string; action: string }) {
const Icon = icon === "launches" ? Layers3 : icon === "holdings" ? Wallet : ArrowDownLeft;
return
{title}
{description}
{action}
;
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/PointsAdmin.tsx b/app/src/components/points/PointsAdmin.tsx
new file mode 100644
index 00000000..79bfa074
--- /dev/null
+++ b/app/src/components/points/PointsAdmin.tsx
@@ -0,0 +1,171 @@
+"use client";
+
+import { useEffect, useRef, 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, 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";
+import { friendlyError } from "@/lib/errors";
+import { nonce } from "@/lib/nonce";
+
+type Row = { rank: number; wallet: string; points: number; eligible: boolean; why: string };
+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 its answer carried no boards (an older server, a blip). */
+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; 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();
+ const { chainId } = useAccount();
+ const config = useConfig();
+ const chain: ChainKey = (Object.keys(CHAINS) as ChainKey[]).find((k) => CHAINS[k].id === chainId) ?? DEFAULT_CHAIN;
+ // 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 [confirmEnd, setConfirmEnd] = 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;
+ const n = nonce();
+ const ts = nowMs();
+ const wallet = await getWalletClient(config);
+ // 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 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");
+ return d;
+ }
+ async function run(action: PointsAdminAction, extra: Record = {}) {
+ const who = me;
+ setBusy(true);
+ setFailed(null);
+ try {
+ // 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 (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]}. Press Load to see where things stand.` });
+ }
+ } catch (e) {
+ if (current.current === who) setFailed({ wallet: who, message: 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) => (
+
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).
+ )}
+
+ {!running ? : 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}
+
+ {data?.previous ? (
+ // the season shown before this one: its boards can be hidden or shown again while this one runs hidden
+
+ ) : null}
+ {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}
+ >
+ ) : null}
+
+ );
+}
diff --git a/app/src/components/points/SeasonCard.tsx b/app/src/components/points/SeasonCard.tsx
new file mode 100644
index 00000000..95eec78d
--- /dev/null
+++ b/app/src/components/points/SeasonCard.tsx
@@ -0,0 +1,56 @@
+"use client";
+
+import Link from "next/link";
+import { ArrowRight, Trophy } from "lucide-react";
+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 (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",
+};
+
+/**
+ * /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);
+ 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) => (
+
+
{label}{rank ? #{rank} : null}
+
{points.toLocaleString("en-US")}
+ {why ?
{why}
: null}
+
+ );
+ 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 (
+
+
+
{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 with the next points update.
+
{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
new file mode 100644
index 00000000..a0d4bf4f
--- /dev/null
+++ b/app/src/components/points/YourStanding.tsx
@@ -0,0 +1,29 @@
+"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 ended = p.season.ended;
+ const final = p.season.final;
+ // 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;
+ const why = board === "creator" ? me.why_creator : me.why_scout;
+ return (
+
+
+
You
+
{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
new file mode 100644
index 00000000..05011733
--- /dev/null
+++ b/app/src/components/points/usePoints.ts
@@ -0,0 +1,52 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { WalletPoints } from "@/lib/points/server";
+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;
+
+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
+
+/**
+ * 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. 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;
+ 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) throw new Error(String(r.status));
+ 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;
+ next(REFRESH_MS);
+ } catch {
+ /* points are a side panel: say nothing on a blip, try again soon */
+ next(RETRY_MS[Math.min(failures++, RETRY_MS.length - 1)]);
+ }
+ };
+ id = setTimeout(() => void read(), 0);
+ return () => {
+ alive = false;
+ clearTimeout(id);
+ };
+ }, [me]);
+ return state && state.wallet === me ? state.v : null;
+}
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..a1a02d66 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,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); 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/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 2ff9e856..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) ?? []);
}
/**
@@ -190,10 +197,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 +395,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 +430,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 +468,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 +534,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 +600,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 +611,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 +716,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 [];
@@ -784,3 +800,17 @@ 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, 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
new file mode 100644
index 00000000..a972c2da
--- /dev/null
+++ b/app/src/lib/points/auth.test.ts
@@ -0,0 +1,39 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { buildPointsAdminMessage, normalizeSeasonDays, normalizeSeasonId } 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);
+ }
+});
+
+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
new file mode 100644
index 00000000..7d652622
--- /dev/null
+++ b/app/src/lib/points/auth.ts
@@ -0,0 +1,38 @@
+/** 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);
+}
+
+/** 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 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, 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; 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()}`,
+ ].join("\n");
+}
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
new file mode 100644
index 00000000..de6f2e26
--- /dev/null
+++ b/app/src/lib/points/score.test.ts
@@ -0,0 +1,320 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+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);
+const NOW = START + 10 * DAY;
+const CREATOR = "0xc0";
+const RECIP = "0xfe";
+const E18 = 10n ** 18n;
+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")}`;
+
+// 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: [], 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 */
+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 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 = {}) {
+ return input({ swaps: buyers.map((b) => b.swap), firstBuys: buyers.map((b) => b.first), moves: buyers.flatMap((b) => b.moves), ...over });
+}
+
+// ── 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)!;
+ assert.equal(c.creatorWhy.verifiedHolders, 1);
+ assert.equal(c.creatorWhy.holders, 1);
+ // 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));
+});
+
+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("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("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("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("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, 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, 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");
+});
+
+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) 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);
+});
+
+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);
+});
+
+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 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: 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, 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, 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");
+});
+
+// ── 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));
+ assert.equal(scoreSeason(build(socks, { launches: [fake], eligible: new Set([w(1)]) })).wallets.size, 0);
+});
+
+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, 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);
+});
+
+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, 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");
+ 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, 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);
+ assert.equal(r.wallets.get(w(1))?.scout ?? 0, 0);
+});
+
+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, tx: `0xrelay${i}`, 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("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)]) }));
+ assert.equal(r.wallets.has(w(1)), false);
+ assert.equal(r.wallets.get(CREATOR)?.creatorWhy.verifiedHolders ?? 0, 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: { ...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 },
+ ];
+ 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");
+});
+
+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
new file mode 100644
index 00000000..12f2cfc3
--- /dev/null
+++ b/app/src/lib/points/score.ts
@@ -0,0 +1,478 @@
+/**
+ * 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. Every rule is
+ * 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), 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,
+ * 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:
+ *
+ * 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, 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 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 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
+ *
+ * Only points-eligible wallets rank; everyone else sees what is waiting for them.
+ */
+
+export const RULES = {
+ sniperBlocks: 3,
+ sniperMs: 12_000,
+ minTradeUsd: 5,
+ holderMinUsd: 5,
+ verifiedHolderMinUsd: 1,
+ verifiedHolder: 30,
+ holder: 5,
+ weekBonus: 0.5,
+ creatorFeePointsPerUsd: 10,
+ creatorFeeCapPerTraderDay: 50,
+ bestTokensPerDay: 3,
+ early: 100,
+ earlyStillHolding: 50,
+ earlyFirstBuyers: 25,
+ earlyHoldersTarget: 50,
+ hold: 5,
+ holdTokensPerDay: 20,
+ 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,
+} 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 → 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; 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; tx: string };
+
+export type ScoreInput = {
+ seasonStart: number;
+ seasonEnd: number;
+ now: number;
+ launches: readonly ScoreLaunch[];
+ /** 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;
+ * `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 */
+ linked: ReadonlyMap>;
+ /** protocol addresses (pool manager, routers, locker, factory, zero, dead) */
+ system: ReadonlySet;
+ 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 };
+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
+ 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, 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 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;
+ // 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) {
+ const k = `${m.key}|${m.wallet}`;
+ const at = `${m.block}:${m.logIndex}`;
+ const byAt = byPair.get(k) ?? new Map();
+ const cur = byAt.get(at);
+ 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 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);
+ 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) {
+ 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);
+ }
+ }
+ /** 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;
+ };
+
+ /** 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);
+ }
+ for (const list of realBuyers.values()) list.sort((a, b) => a.block - b.block || a.logIndex - b.logIndex || a.time - b.time);
+
+ // 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, 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 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.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) => 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);
+
+ // ── 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, holders: [], fees: [], dumped: false };
+ perToken.set(l.key, t);
+ }
+ return t;
+ };
+ for (const [key, list] of realHolders) {
+ const l = launches.get(key)!;
+ 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 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) || !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 });
+ }
+ // 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 = 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();
+ for (const t of perToken.values()) {
+ if (input.flagged.has(t.l.launcher)) continue;
+ const list = byCreator.get(t.l.launcher) ?? [];
+ list.push(t);
+ byCreator.set(t.l.launcher, list);
+ }
+ for (const [creator, tokens] of byCreator) {
+ const s = get(creator);
+ 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 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 });
+ }
+ }
+ return best;
+ };
+ /** 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 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, 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 byDay = new Map();
+ for (const t of live) {
+ const d = utcDay(t.l.launchTime);
+ byDay.set(d, [...(byDay.get(d) ?? []), t]);
+ }
+ const kept: TokenCreator[] = [];
+ 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);
+ 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++;
+ }
+ }
+ }
+
+ // ── scout points ──
+ 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 + (halfLeft(key, f.wallet, f.tx, f.tokenRaw) ? RULES.earlyStillHolding : 0);
+ s.scoutWhy.early++;
+ }
+ }
+ // 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 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(f.wallet);
+ w.scout += RULES.hold;
+ w.scoutWhy.holds++;
+ }
+ const scoutFeeUsed = new Map();
+ for (const s of outside) {
+ 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;
+ if (fee <= 0) continue;
+ 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;
+ scoutFeeUsed.set(k, 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) wallets.delete(w);
+ }
+ 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";
+/** 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";
+ 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. */
+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 from verified traders`);
+ } 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..ae4a9871
--- /dev/null
+++ b/app/src/lib/points/server.ts
@@ -0,0 +1,549 @@
+import "server-only";
+import { isAddress } from "viem";
+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 { 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 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.
+ *
+ * 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 (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), 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 };
+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, 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, 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;
+}
+
+/** Whether a season's end has passed. */
+export function seasonEnded(s: Pick, now = Date.now()): boolean {
+ return new Date(s.ends_at).getTime() <= now;
+}
+
+/**
+ * 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 Boolean(s.computed_until && new Date(s.computed_until).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 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));
+}
+
+/** How long past its end a season's final count waits for swaps still being attributed. */
+const SETTLE_WAIT_MS = 60 * 60_000;
+/** How long past its end it waits for a normally-priced quote's price (a feed gone for good then drops its tokens). */
+const PRICE_WAIT_MS = 24 * 60 * 60_000;
+/** Whether any swap in [from, to) is still being attributed (unchecked, or its EntryPoint receipt not read yet). */
+async function unattributedIn(read: Db, from: string, to: string): 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();
+ const start = new Date(season.starts_at).getTime();
+ const end = new Date(season.ends_at).getTime();
+ const now = Date.now();
+ 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);
+ // 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
+ 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: 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);
+
+ // 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; 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();
+ 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);
+
+ // 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); 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;
+ 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);
+ if (q.usd === null && MAIN_QUOTES.has(q.key)) return { skipped: `no price for ${q.key}` };
+ 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;
+ }
+ const perToken = l.sqrt_price_x96 ? quotePerToken(BigInt(l.sqrt_price_x96), q.decimals) : 0;
+ 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()),
+ 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 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 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; 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, 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 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 }[]>`
+ 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 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(), 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);
+ const set = linked.get(k) ?? new Set();
+ set.add(r.to_addr);
+ linked.set(k, set);
+ }
+ 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) {
+ 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,
+ 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()];
+ 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 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);
+ 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
+ }
+ // 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)" };
+ 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 };
+}
+
+let running = false;
+let lastCheck = 0;
+const EVERY_MS = 60 * 60_000;
+
+/** 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;
+ 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(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(() => {});
+ }
+ } finally {
+ reserved.release();
+ }
+}
+
+/**
+ * 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 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;
+ lastCheck = Date.now();
+ running = true;
+ try {
+ const season = await currentSeason();
+ 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 {
+ 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 (seasonFinal(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 };
+
+/** One board of a season (eligible wallets, by rank) with the names to show. */
+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)) };
+}
+
+/** 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;
+ 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 };
+}
+
+/**
+ * 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()}`;
+ // (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 ────────────────────────────────────────────────────────────────────
+/**
+ * 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 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()!;
+ 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 ' || (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;
+}
+
+/**
+ * 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. (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()!;
+ return db.begin(async (tx) => {
+ const t = tx as unknown as Db;
+ 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;
+ }
+ 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}`;
+}
+
+/** 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 (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 && 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). */
+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 (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"));
+ 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