Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
9203ff1
feat(profiles): usernames, X-verified tick, smart-wallet trade attrib…
kevincodex1 Oct 7, 2026
ff96112
fix(profiles): review round 1 (attribution evidence, verify needs the…
kevincodex1 Oct 7, 2026
2759931
fix(profiles): review round 2 (signing chain, execution-window attrib…
kevincodex1 Oct 7, 2026
079b61f
feat(profiles): the link card draws the wallet mark when there is no …
kevincodex1 Oct 7, 2026
8a1fbdf
fix(profiles): review round 3 (unreadable chain is not "undeployed", …
kevincodex1 Oct 7, 2026
2494eea
fix(profiles): PR review (verify key, capped body reads, airtight pos…
kevincodex1 Oct 7, 2026
950edfe
fix(profiles): strip oEmbed tags in one scan instead of a regex repla…
kevincodex1 Oct 7, 2026
67bfd32
fix(profiles): keep an unverified X handle when the profile form is r…
kevincodex1 Oct 7, 2026
60bbf2d
fix(profiles): CodeRabbit full review (review hand-off race, receipt-…
kevincodex1 Oct 7, 2026
b280f1e
fix(profiles): CodeRabbit full review 2 (moderation under the profile…
kevincodex1 Oct 7, 2026
8860645
fix(profiles): CodeRabbit full review 3 + independent review (zero-ad…
kevincodex1 Oct 8, 2026
30b050d
fix(profiles): CodeRabbit full review 4 (per-wallet state never cross…
kevincodex1 Oct 8, 2026
5fa7e4c
fix(profiles): CodeRabbit full review 5 (X alone decides a post, unsu…
kevincodex1 Oct 8, 2026
e3039a4
test(profiles): match stubbed X sources by exact hostname (CodeQL)
kevincodex1 Oct 8, 2026
6c88cef
fix(profiles): only the newest profile lookup lands on the identity c…
kevincodex1 Oct 8, 2026
658b50d
fix(profiles): ultra review + CodeRabbit on 6c88cef
kevincodex1 Oct 9, 2026
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
13 changes: 13 additions & 0 deletions app/db/concurrent-indexes.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
-- Indexes on the big, busy tables, built by scripts/migrate.mjs AFTER the schema transaction commits, one at a time
-- with CREATE INDEX CONCURRENTLY: reads and writes on the table never wait for the build. (Inside the schema
-- transaction a build would hold the exclusive lock an ALTER TABLE on that table already took, and every query on
-- it would queue behind the whole build.) One statement per entry, each `CREATE INDEX CONCURRENTLY IF NOT EXISTS`;
-- an index a failed build left invalid is dropped and built again on the next deploy. Only for indexes whose columns
-- schema.sql already has.

-- swap attribution (2026-10-07): the unchecked set, every swap until the history drain reaches it, then only the
-- newest few (the partial index stays small); EntryPoint calls whose receipt is not read yet
CREATE INDEX CONCURRENTLY IF NOT EXISTS bb_launch_swaps_unattributed_idx ON bb_launch_swaps (chain_id, block_number DESC) WHERE trader_via IS NULL;
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);
70 changes: 70 additions & 0 deletions app/db/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -323,3 +323,73 @@ 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;
-- 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
35 changes: 34 additions & 1 deletion app/scripts/migrate.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@
*
* DATABASE_URL=postgres://… node scripts/migrate.mjs
*
* Then db/concurrent-indexes.sql: indexes on the big, busy tables, built one by
* one AFTER the transaction commits with CREATE INDEX CONCURRENTLY, so no read
* or write on those tables waits for a build (inside the transaction a build
* would hold the exclusive lock an ALTER TABLE already took). An index a failed
* build left invalid is dropped and built again. A failed build exits 1 too.
*
* Only depends on `postgres` + node builtins so it runs from the standalone
* image (Dockerfile copies db/ + this file; next.config.ts keeps `postgres`
* external so it is traced into .next/standalone/node_modules).
Expand Down Expand Up @@ -45,14 +51,30 @@ if (!url) {
const here = path.dirname(fileURLToPath(import.meta.url));
const file = process.env.BASEBID_SCHEMA_FILE || path.resolve(here, "../db/schema.sql");
const text = await readFile(file, "utf8");
const hash = createHash("sha256").update(text).digest("hex");
const concurrentFile = process.env.BASEBID_CONCURRENT_FILE || path.resolve(path.dirname(file), "concurrent-indexes.sql");
const concurrentText = await readFile(concurrentFile, "utf8").catch(() => "");
// one CREATE INDEX CONCURRENTLY IF NOT EXISTS <name> per statement (comments dropped)
const concurrent = concurrentText
.replace(/--[^\n]*/g, "")
.split(";")
.map((s) => s.trim())
.filter(Boolean)
.map((s) => {
const m = /^CREATE INDEX CONCURRENTLY IF NOT EXISTS (\w+) ON /i.exec(s);
if (!m) throw new Error(`concurrent-indexes.sql: not a CREATE INDEX CONCURRENTLY IF NOT EXISTS statement: ${s.slice(0, 80)}`);
return { name: m[1], sql: s };
});
const hash = createHash("sha256").update(text).update(concurrentText).digest("hex");
const appliedBy = process.env.FLY_IMAGE_REF || process.env.FLY_MACHINE_ID || hostname();

const sql = postgres(url, { max: 1, connect_timeout: 15, idle_timeout: 5, onnotice: () => {} });
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 All @@ -65,6 +87,17 @@ try {
where table_schema = current_schema() and table_name like 'bb\\_%'`;
return { changed, history, tables };
});
// outside any transaction: each build lets reads and writes go on; a long-held lock elsewhere fails it after 30 s
await sql`SET lock_timeout = '30s'`;
await sql`SET statement_timeout = '20min'`;
for (const ix of concurrent) {
const [state] = await sql`select i.indisvalid as valid from pg_class c join pg_index i on i.indexrelid = c.oid where c.relname = ${ix.name} and c.relnamespace = current_schema()::regnamespace`;
if (state && state.valid) continue;
const t1 = Date.now();
if (state) await sql.unsafe(`DROP INDEX CONCURRENTLY IF EXISTS ${ix.name}`); // left invalid by a failed build
await sql.unsafe(ix.sql);
console.log(`migrate: built ${ix.name} concurrently in ${Date.now() - t1}ms`);
}
console.log(
`migrate: ok schema=${hash.slice(0, 12)} ${changed ? "applied (new hash)" : "re-applied (unchanged)"} ` +
`tables=${tables} history=${history} ${Date.now() - t0}ms`,
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>
);
}
30 changes: 30 additions & 0 deletions app/src/app/api/profile/admin/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
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.
* Approve / reject also carry `claim`: the code of the post the admin reviewed, signed with the action.
*/
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, claim: b.claim, 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, row: r.row }, { headers: { "cache-control": "no-store" } });
} catch (err) {
console.error("[profile] moderation failed:", err instanceof Error ? err.message : err);
return NextResponse.json({ error: "could not apply" }, { status: 502 });
}
}
35 changes: 35 additions & 0 deletions app/src/app/api/profile/check/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
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();
// a check that could not run says so (the form then shows nothing); the save checks for real either way
if (!db) return NextResponse.json({ error: "could not check right now" }, { status: 503 });
try {
const [owner] = await db<{ wallet: string; x_status: string }[]>`SELECT wallet, x_status 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'`;
// a retired name is held by a marker that is no wallet, so it is never "mine"
const mine = (w: string | undefined) => Boolean(w && isAddress(wallet) && w === wallet);
// the save's rule for a verified X owner taking their own handle: it yields an unverified holder and any hold but
// a retirement (the verified handle is public, so this says nothing new)
const [self] = isAddress(wallet) ? await db<{ x_status: string; x_handle: string | null }[]>`SELECT x_status, x_handle FROM bb_profiles WHERE wallet = ${wallet} AND deleted_at IS NULL` : [];
const claimingOwnX = self?.x_status === "verified" && self.x_handle === c.username;
const available =
(!owner || mine(owner.wallet) || (claimingOwnX && owner.x_status !== "verified")) && (!held || mine(held.wallet) || (claimingOwnX && held.wallet !== "retired"));
return NextResponse.json(available ? { available: true } : { available: false, error: "that username is taken" }, { headers: { "cache-control": "no-store" } });
} catch {
return NextResponse.json({ error: "could not check right now" }, { status: 503 });
}
}
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 });
}
}
Loading
Loading