diff --git a/app/db/schema.sql b/app/db/schema.sql index 00cc689c..0ffab748 100644 --- a/app/db/schema.sql +++ b/app/db/schema.sql @@ -323,3 +323,76 @@ 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/.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:' 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; +CREATE INDEX 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 IF NOT EXISTS bb_launch_swaps_trader_idx ON bb_launch_swaps (trader, block_number DESC); diff --git a/app/scripts/migrate.mjs b/app/scripts/migrate.mjs index 0f5340a3..38b02c87 100644 --- a/app/scripts/migrate.mjs +++ b/app/scripts/migrate.mjs @@ -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; diff --git a/app/src/app/admin/page.tsx b/app/src/app/admin/page.tsx index df8dcfa9..9de5d3b3 100644 --- a/app/src/app/admin/page.tsx +++ b/app/src/app/admin/page.tsx @@ -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 (

Moderation

-

Reported posts. Every action is a signature from an admin wallet.

+

Reported posts and profiles. Every action is a signature from an admin wallet.

+
); } diff --git a/app/src/app/api/profile/admin/route.ts b/app/src/app/api/profile/admin/route.ts new file mode 100644 index 00000000..a430eba3 --- /dev/null +++ b/app/src/app/api/profile/admin/route.ts @@ -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 }); + } catch (err) { + console.error("[profile] moderation failed:", err instanceof Error ? err.message : err); + return NextResponse.json({ error: "could not apply" }, { status: 502 }); + } +} diff --git a/app/src/app/api/profile/check/route.ts b/app/src/app/api/profile/check/route.ts new file mode 100644 index 00000000..23f28e49 --- /dev/null +++ b/app/src/app/api/profile/check/route.ts @@ -0,0 +1,30 @@ +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 }[]>`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'`; + // 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); + 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({ error: "could not check right now" }, { status: 503 }); + } +} diff --git a/app/src/app/api/profile/delete/route.ts b/app/src/app/api/profile/delete/route.ts new file mode 100644 index 00000000..1e739b84 --- /dev/null +++ b/app/src/app/api/profile/delete/route.ts @@ -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 }); + } +} diff --git a/app/src/app/api/profile/names/route.ts b/app/src/app/api/profile/names/route.ts new file mode 100644 index 00000000..635d09f7 --- /dev/null +++ b/app/src/app/api/profile/names/route.ts @@ -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 }); + } +} diff --git a/app/src/app/api/profile/route.ts b/app/src/app/api/profile/route.ts new file mode 100644 index 00000000..1813eea2 --- /dev/null +++ b/app/src/app/api/profile/route.ts @@ -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 }); + 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 }); + } +} diff --git a/app/src/app/api/profile/x/route.ts b/app/src/app/api/profile/x/route.ts new file mode 100644 index 00000000..c7d85495 --- /dev/null +++ b/app/src/app/api/profile/x/route.ts @@ -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 }); + } +} diff --git a/app/src/app/t/[chain]/[token]/page.tsx b/app/src/app/t/[chain]/[token]/page.tsx index 0ca669ca..75c66e22 100644 --- a/app/src/app/t/[chain]/[token]/page.tsx +++ b/app/src/app/t/[chain]/[token]/page.tsx @@ -18,6 +18,8 @@ import TokenDetails from "@/components/launchpad/TokenDetails"; import TokenTrades from "@/components/launchpad/TokenTrades"; import LaunchReceipt from "@/components/launchpad/LaunchReceipt"; import TokenAbout from "@/components/launchpad/TokenAbout"; +import NamesProvider from "@/components/profile/NamesProvider"; +import { namesFor } from "@/lib/profiles/server"; import { getHolderPanel } from "@/lib/launchpad/holdersServer"; import { memo } from "@/lib/launchpad/memo"; import { ago, nowMs } from "@/lib/launchpad/time"; @@ -55,6 +57,7 @@ const GITLAWB_ORIGIN: Record = { base: " on Base", robinhood: export const dynamic = "force-dynamic"; +/** Title, description and link-card metadata for a token page. */ export async function generateMetadata({ params }: { params: Promise<{ chain: string; token: string }> }): Promise { const { chain, token } = await params; const l = isChainKey(chain) && isAddress(token) ? await getLaunch(chain, token) : null; @@ -72,6 +75,7 @@ export async function generateMetadata({ params }: { params: Promise<{ chain: st }; } +/** A token's page: market, chart, trades, holders and comments, with every wallet named by its profile where it has one. */ export default async function TokenPage({ params }: { params: Promise<{ chain: string; token: string }> }) { const { chain, token } = await params; if (!isChainKey(chain) || !isAddress(token)) notFound(); @@ -90,10 +94,14 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s const unlisted = quote.key === "other"; const stockQuote = quote.key === "stock" ? stockByAddress(chain, l.quote) : null; const [swaps, holders, tint] = await Promise.all([getSwaps(chain, l.token, quote.decimals, 40), memo(`holders:${chain}:${l.token}`, 5_000, () => getHolderPanel(chain, l.token)), tokenTint(l.image_url, l.token)]); + // names for every wallet the server renders (trades, creator, fee recipients, top holders): the first paint shows them + const nameWallets = [l.launcher, ...swaps.map((s) => s.trader), ...l.recipients.map((r) => r.payout), ...(holders?.top ?? []).map((h) => h.address)].filter((w): w is string => Boolean(w)); + const known = await namesFor(nameWallets).catch(() => ({}) as Awaited>); + const names = Object.fromEntries([...new Set(nameWallets.map((w) => w.toLowerCase()))].map((w) => [w, known[w] ?? null])); const now = nowMs(); const mode = feeModeOf(l.lp_fee, l.recipients); const cap = capDisplay(l.fdv_quote, l.quote_usd, { key: l.quote_key, symbol: l.quote_symbol, decimals: l.quote_decimals }); - const proof = proofFacts({ holders, symbol: l.symbol, launcher: l.launcher, lpFee: l.lp_fee, mode, recipients: l.recipients.length }); + const proof = proofFacts({ holders, symbol: l.symbol, launcher: l.launcher, launcherName: names[l.launcher.toLowerCase()]?.u ?? null, lpFee: l.lp_fee, mode, recipients: l.recipients.length }); const locker = launchpad(chain).locker; const proofLinks: Partial> = { ...(locker ? { lock: { href: explorerAddress(chain, locker), label: "View locker", external: true } } : {}), @@ -139,7 +147,7 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s }); return ( - <> +