Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions app/db/concurrent-indexes.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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);
36 changes: 36 additions & 0 deletions app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -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;
4 changes: 3 additions & 1 deletion app/src/app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -11,10 +12,11 @@ export default function AdminPage() {
<main className="mx-auto max-w-3xl px-4 pt-8 sm:pt-10 pb-16 space-y-6">
<header>
<h1 className="font-display font-bold tracking-[-0.02em] text-ink text-3xl">Moderation</h1>
<p className="mt-2 text-base text-body">Reported posts and profiles. Every action is a signature from an admin wallet.</p>
<p className="mt-2 text-base text-body">Reported posts, profiles and season points. Every action is a signature from an admin wallet.</p>
</header>
<AdminQueue />
<ProfileQueue />
<PointsAdmin />
</main>
);
}
91 changes: 91 additions & 0 deletions app/src/app/api/points/admin/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) => 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 });
}
}
19 changes: 19 additions & 0 deletions app/src/app/api/points/board/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
31 changes: 31 additions & 0 deletions app/src/app/api/points/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
Loading
Loading