Skip to content
Open
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
72 changes: 72 additions & 0 deletions app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -323,3 +323,75 @@ CREATE TABLE IF NOT EXISTS bb_quote_tokens (
-- An optional wide image the creator uploads beside the logo. Same rules as image_url (https only; uploads are
-- re-encoded server-side, here to a 1500×500 WebP). NULL means no banner: the market cards draw one from the logo.
ALTER TABLE bb_launch_meta ADD COLUMN IF NOT EXISTS banner_url text;

-- ── profiles (2026-10-07) ──────────────────────────────────────────────────
-- Optional public profile per wallet: a unique username shown wherever the wallet appears (trades, holders, posts,
-- "launched by"). Every write is a wallet signature (src/lib/profiles). The X tick comes only from a public post
-- that carries a one-time code bound to this wallet and the claimed handle (lib/profiles/xpost.ts); the claimed
-- handle is never shown until it is verified. No email, no IP, no off-site identity beyond the public X account.
CREATE TABLE IF NOT EXISTS bb_profiles (
wallet text PRIMARY KEY, -- lowercase hex
username text NOT NULL, -- lowercase [a-z0-9_]{3,20}
display_name text NOT NULL,
bio text,
avatar_key text, -- t/<hex>.webp in our image store, served same-origin
x_handle text, -- claimed handle (lowercase); public only once verified
x_user_id text, -- X account id ('h:<handle>' when only the handle was readable)
x_post_id text,
x_verified_at timestamptz,
x_account_created timestamptz,
x_followers integer,
x_status text NOT NULL DEFAULT 'none' CHECK (x_status IN ('none','verified','post_missing','pending_review')),
x_checked_at timestamptz,
hidden boolean NOT NULL DEFAULT false, -- admin: the wallet shows as a plain address again
points_flag text, -- admin: 'excluded' keeps the wallet off any points board
points_flag_reason text,
username_changed_at timestamptz,
deleted_at timestamptz, -- deleted by its owner: the row stays (flags, created_at and the rename clock survive a re-create)
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX IF NOT EXISTS bb_profiles_username_uq ON bb_profiles (username);
CREATE UNIQUE INDEX IF NOT EXISTS bb_profiles_x_user_uq ON bb_profiles (x_user_id) WHERE x_user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS bb_profiles_x_review_idx ON bb_profiles (x_status, updated_at DESC) WHERE x_status <> 'none';
-- A released username (rename or delete) stays reserved for its previous wallet for 30 days.
CREATE TABLE IF NOT EXISTS bb_username_holds (
username text PRIMARY KEY,
wallet text NOT NULL,
released_at timestamptz NOT NULL DEFAULT now()
);
-- single-use nonces for profile / profile-moderation signatures (client-generated, server-consumed)
CREATE TABLE IF NOT EXISTS bb_profile_nonces (
nonce text PRIMARY KEY,
wallet text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- One-time X verification codes: bound to the wallet AND the handle the wallet signed for, so a copied code is
-- useless from any other account. The code goes into a public post, so verifying also needs a private key that was
-- returned only to the signer (kept here as a hash). review: NULL until a post is submitted while every lookup is down.
CREATE TABLE IF NOT EXISTS bb_x_codes (
code text PRIMARY KEY, -- OL-XXXXXXXX
wallet text NOT NULL,
x_handle text NOT NULL, -- lowercase
issued_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
used_at timestamptz,
post_id text,
submitted_at timestamptz,
review text CHECK (review IN ('pending','approved','rejected')),
secret_hash text -- sha256 of the private verify key only the signer was given
);
ALTER TABLE bb_x_codes ADD COLUMN IF NOT EXISTS secret_hash text;
CREATE INDEX IF NOT EXISTS bb_x_codes_wallet_idx ON bb_x_codes (wallet, issued_at DESC);

-- ── swap attribution (2026-10-07) ───────────────────────────────────────────
-- trader is tx.from, except with proof another account authorized the call (lib/launchpad/attribution.ts): an ERC-4337
-- EntryPoint transaction credits the sender of the user operation whose execution contains the swap ('userop').
-- tx_from keeps the sender; trader_via NULL = not checked yet, 'receipt_pending' = an EntryPoint call whose receipt is not
-- read yet (retried), 'unread' = the evidence could not be read (sender kept). Both open states are settled from the chain.
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;
-- the unchecked set: every swap until the history drain reaches it, then only the newest few (partial index stays small)
CREATE INDEX IF NOT EXISTS bb_launch_swaps_unattributed_idx ON bb_launch_swaps (chain_id, block_number DESC) WHERE trader_via IS NULL OR trader_via = 'receipt_pending';
-- one wallet's trades (profile pages, /me, posting eligibility, points) without scanning every swap
CREATE INDEX IF NOT EXISTS bb_launch_swaps_trader_idx ON bb_launch_swaps (trader, block_number DESC);
3 changes: 3 additions & 0 deletions app/scripts/migrate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ const t0 = Date.now();
let exitCode = 0;
try {
const { changed, history, tables } = await sql.begin(async (tx) => {
// an ALTER TABLE takes an exclusive lock even when the column exists: never queue behind a long read (and block
// every read behind us) for more than a few seconds; a failed release just fails the deploy, which can be retried
await tx`SET LOCAL lock_timeout = '10s'`;
await tx.unsafe(text);
const [last] = await tx`select schema_sha256 from bb_migrations order by id desc limit 1`;
const changed = last?.schema_sha256 !== hash;
Expand Down
5 changes: 4 additions & 1 deletion app/src/app/admin/page.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import type { Metadata } from "next";
import AdminQueue from "@/components/launchpad/AdminQueue";
import ProfileQueue from "@/components/profile/ProfileQueue";

export const metadata: Metadata = { title: "Moderation", robots: { index: false, follow: false } };
export const dynamic = "force-dynamic";

/** The moderation page: reported posts and the profiles queue, each action an admin-wallet signature. */
export default function AdminPage() {
return (
<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. Every action is a signature from an admin wallet.</p>
<p className="mt-2 text-base text-body">Reported posts and profiles. Every action is a signature from an admin wallet.</p>
</header>
<AdminQueue />
<ProfileQueue />
</main>
);
}
29 changes: 29 additions & 0 deletions app/src/app/api/profile/admin/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { rateLimited } from "@/lib/launchpad/editServer";
import { clientIp, readJson } from "@/lib/profiles/http";
import { listProfilesForReview, moderateProfile } from "@/lib/profiles/server";

export const dynamic = "force-dynamic";

/**
* POST {action, …signed} → one admin-signed request: action "list" returns the review queue (X posts waiting for a
* person, with the code that was issued for each, and the newest profiles); any other action moderates one profile.
*/
export async function POST(req: Request) {
if (rateLimited(`pmod:ip:${clientIp(req)}`, 60)) return NextResponse.json({ error: "slow down" }, { status: 429 });
const b = await readJson(req);
if (!b) return NextResponse.json({ error: "bad json" }, { status: 400 });
try {
if (b.action === "list") {
const r = await listProfilesForReview({ chain: b.chain, wallet: b.wallet, nonce: b.nonce, ts: b.ts, signature: b.signature });
if (!r.ok) return NextResponse.json({ error: r.error }, { status: r.status });
return NextResponse.json({ pending: r.pending, recent: r.recent }, { headers: { "cache-control": "no-store" } });
}
const r = await moderateProfile({ action: b.action, target: b.target, reason: b.reason, chain: b.chain, wallet: b.wallet, nonce: b.nonce, ts: b.ts, signature: b.signature });
if (!r.ok) return NextResponse.json({ error: r.error }, { status: r.status });
return NextResponse.json({ ok: true });
} catch (err) {
console.error("[profile] moderation failed:", err instanceof Error ? err.message : err);
return NextResponse.json({ error: "could not apply" }, { status: 502 });
}
}
29 changes: 29 additions & 0 deletions app/src/app/api/profile/check/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { isAddress } from "viem";
import { rateLimited } from "@/lib/launchpad/editServer";
import { maybeDb } from "@/lib/db";
import { clientIp } from "@/lib/profiles/http";
import { checkUsername } from "@/lib/profiles/validate";

export const dynamic = "force-dynamic";

/** GET /api/profile/check?username=name[&wallet=0x…] → {available, error?} for the form (the save re-checks everything). */
export async function GET(req: Request) {
if (rateLimited(`ucheck:ip:${clientIp(req)}`, 120)) return NextResponse.json({ error: "slow down" }, { status: 429 });
const u = new URL(req.url);
const c = checkUsername(u.searchParams.get("username"));
if (!c.ok) return NextResponse.json({ available: false, error: c.error });
const wallet = (u.searchParams.get("wallet") ?? "").toLowerCase();
const db = maybeDb();
if (!db) return NextResponse.json({ available: true });
try {
const [owner] = await db<{ wallet: string }[]>`SELECT wallet FROM bb_profiles WHERE username = ${c.username}`;
const [held] = await db<{ wallet: string }[]>`SELECT wallet FROM bb_username_holds WHERE username = ${c.username} AND released_at > now() - interval '30 days'`;
// the zero address holds retired names: nobody is it
const mine = (w: string | undefined) => Boolean(w && isAddress(wallet) && w === wallet && !/^0x0{40}$/.test(w));
const available = (!owner || mine(owner.wallet)) && (!held || mine(held.wallet));
return NextResponse.json(available ? { available: true } : { available: false, error: "that username is taken" }, { headers: { "cache-control": "no-store" } });
} catch {
return NextResponse.json({ available: true });
}
}
21 changes: 21 additions & 0 deletions app/src/app/api/profile/delete/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { NextResponse } from "next/server";
import { rateLimited } from "@/lib/launchpad/editServer";
import { clientIp, readJson } from "@/lib/profiles/http";
import { deleteProfile } from "@/lib/profiles/server";

export const dynamic = "force-dynamic";

/** POST {chain, wallet, nonce, ts, signature} → deletes the signer's profile (the username is held for them 30 days). */
export async function POST(req: Request) {
if (rateLimited(`profile: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 });
try {
const r = await deleteProfile({ chain: b.chain, wallet: b.wallet, nonce: b.nonce, ts: b.ts, signature: b.signature });
if (!r.ok) return NextResponse.json({ error: r.error }, { status: r.status });
return NextResponse.json({ ok: true });
} catch (err) {
console.error("[profile] delete failed:", err instanceof Error ? err.message : err);
return NextResponse.json({ error: "could not delete, try again" }, { status: 502 });
}
}
22 changes: 22 additions & 0 deletions app/src/app/api/profile/names/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { NextResponse } from "next/server";
import { isAddress } from "viem";
import { rateLimited } from "@/lib/launchpad/editServer";
import { clientIp } from "@/lib/profiles/http";
import { namesFor } from "@/lib/profiles/server";

export const dynamic = "force-dynamic";
const NAMES_MAX = 100;

/** GET /api/profile/names?w=0x…,0x… (≤100) → {names: {wallet: {u, d, a, v}}} for wallets that have a visible profile. */
export async function GET(req: Request) {
if (rateLimited(`names:ip:${clientIp(req)}`, 240)) return NextResponse.json({ error: "slow down" }, { status: 429 });
const raw = new URL(req.url).searchParams.get("w") ?? "";
const wallets = raw.split(",").map((s) => s.trim().toLowerCase()).filter((s) => isAddress(s)).slice(0, NAMES_MAX);
if (wallets.length === 0) return NextResponse.json({ names: {} });
try {
return NextResponse.json({ names: await namesFor(wallets) }, { headers: { "cache-control": "no-store" } });
} catch (err) {
console.error("[profile] names failed:", err instanceof Error ? err.message : err);
return NextResponse.json({ error: "could not load names" }, { status: 502 });
}
}
40 changes: 40 additions & 0 deletions app/src/app/api/profile/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { NextResponse } from "next/server";
import { isAddress } from "viem";
import { rateLimited } from "@/lib/launchpad/editServer";
import { clientIp, readJson } from "@/lib/profiles/http";
import { getProfile, saveProfile } from "@/lib/profiles/server";
import { normalizeUsername } from "@/lib/profiles/validate";

export const dynamic = "force-dynamic";
const noStore = { "cache-control": "no-store" };

/** GET /api/profile?wallet=0x… | ?username=name → the public profile (404 when there is none). */
export async function GET(req: Request) {
const u = new URL(req.url);
const wallet = (u.searchParams.get("wallet") ?? "").toLowerCase();
const username = normalizeUsername(u.searchParams.get("username"));
if (!isAddress(wallet) && !/^[a-z0-9_]{1,20}$/.test(username)) return NextResponse.json({ error: "bad params" }, { status: 400 });
try {
const profile = await getProfile(isAddress(wallet) ? { wallet } : { username });
if (!profile) return NextResponse.json({ error: "not found" }, { status: 404, headers: noStore });
return NextResponse.json({ profile }, { headers: noStore });
} catch (err) {
console.error("[profile] read failed:", err instanceof Error ? err.message : err);
return NextResponse.json({ error: "could not load profile" }, { status: 502 });
}
}

/** POST {chain, wallet, nonce, ts, signature, fields} → saves the signed profile; returns it and, with an X handle, a code to post. */
export async function POST(req: Request) {
if (rateLimited(`profile: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 });
try {
const r = await saveProfile({ chain: b.chain, wallet: b.wallet, nonce: b.nonce, ts: b.ts, signature: b.signature, fields: (b.fields && typeof b.fields === "object" ? b.fields : {}) as Record<string, unknown> });
if (!r.ok) return NextResponse.json({ error: r.error }, { status: r.status });
return NextResponse.json(r, { headers: noStore });
} catch (err) {
console.error("[profile] save failed:", err instanceof Error ? err.message : err);
return NextResponse.json({ error: "could not save, try again" }, { status: 502 });
}
}
25 changes: 25 additions & 0 deletions app/src/app/api/profile/x/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
import { rateLimited } from "@/lib/launchpad/editServer";
import { clientIp, readJson } from "@/lib/profiles/http";
import { verifyXPost } from "@/lib/profiles/server";

export const dynamic = "force-dynamic";

/**
* POST {wallet, code, secret, postUrl} → checks the post against the wallet's open code. No second signature: the
* private verify key (`secret`) was returned only to the signer at save time and never appears in the post, so a
* request without it does nothing (no lookup, no rate-limit bucket, nothing said about the claim).
*/
export async function POST(req: Request) {
if (rateLimited(`xverify:ip:${clientIp(req)}`, 30, 60 * 60_000)) return NextResponse.json({ error: "too many tries, wait a bit" }, { status: 429 });
const b = await readJson(req);
if (!b) return NextResponse.json({ error: "bad json" }, { status: 400 });
try {
const r = await verifyXPost({ wallet: b.wallet, postUrl: b.postUrl, code: b.code, secret: b.secret });
if (!r.ok) return NextResponse.json({ error: r.error }, { status: r.status });
return NextResponse.json(r, { headers: { "cache-control": "no-store" } });
} catch (err) {
console.error("[profile] x verify failed:", err instanceof Error ? err.message : err);
return NextResponse.json({ error: "could not check the post, try again" }, { status: 502 });
}
}
Loading
Loading