From 9203ff18743685670473f673cb4f09b27f500305 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Wed, 7 Oct 2026 11:49:10 +0800 Subject: [PATCH 01/12] feat(profiles): usernames, X-verified tick, smart-wallet trade attribution Profiles give a wallet a public username, name, picture and bio, shown wherever the wallet appears (trades, holders, comments, "launched by", fee recipients, the community feed) and on /u/ with a link card. Every write is a wallet signature over a message that spells out every field (EOA, ERC-1271 and ERC-6492 all verify), checked before its single-use nonce is spent. Usernames are a-z 0-9 _, brand / chain / staff names and routes are reserved, a dropped name is held 30 days for its old wallet, and a name changes once per 30 days (free on the first day, and always to claim your own verified X handle from an unverified squatter). The tick needs one public post on X carrying a one-time code bound to the wallet and the handle it signed for, so a copied code is useless from any other account. The post is read from X's oEmbed and embed endpoints plus fxtwitter, no API key; one X account belongs to one wallet; the claimed handle stays hidden until verified; posts are re-read about weekly and a deleted one pauses the tick. If X answers nobody, an admin approves it from /admin. The post earns nothing by itself. Swaps were credited to tx.from, which for ERC-4337 smart wallets is the bundler: their trades would never show their name. The indexer now credits the wallet that actually took or paid the token when the sender never touched it (attribution.ts), keeps tx_from for the record, and drains existing history a batch per poll. --- app/db/schema.sql | 67 +++ app/src/app/admin/page.tsx | 4 +- app/src/app/api/profile/admin/route.ts | 35 ++ app/src/app/api/profile/check/route.ts | 28 ++ app/src/app/api/profile/delete/route.ts | 21 + app/src/app/api/profile/names/route.ts | 22 + app/src/app/api/profile/route.ts | 40 ++ app/src/app/api/profile/x/route.ts | 25 + app/src/app/t/[chain]/[token]/page.tsx | 12 +- app/src/app/u/[username]/opengraph-image.tsx | 112 +++++ app/src/app/u/[username]/page.tsx | 140 ++++++ app/src/components/feed-loading.test.ts | 2 +- app/src/components/launchpad/CollectPanel.tsx | 9 +- app/src/components/launchpad/HoldersPanel.tsx | 13 +- .../launchpad/MeDashboard.module.css | 2 +- app/src/components/launchpad/MeDashboard.tsx | 6 +- app/src/components/launchpad/Posts.tsx | 10 +- app/src/components/launchpad/TokenAbout.tsx | 3 +- app/src/components/launchpad/TokenTrades.tsx | 3 +- .../components/launchpad/token-page.test.ts | 2 +- app/src/components/profile/NamesProvider.tsx | 17 + .../components/profile/ProfileIdentity.tsx | 74 +++ app/src/components/profile/ProfileQueue.tsx | 101 ++++ app/src/components/profile/ProfileSheet.tsx | 233 +++++++++ app/src/components/profile/Who.tsx | 57 +++ app/src/components/sections/CommunityFeed.tsx | 6 +- app/src/components/wallet-avatar.test.ts | 10 +- app/src/lib/launchpad/attribution.test.ts | 65 +++ app/src/lib/launchpad/attribution.ts | 49 ++ app/src/lib/launchpad/indexer.ts | 71 ++- app/src/lib/launchpad/loop.ts | 3 + app/src/lib/launchpad/proof.test.ts | 5 + app/src/lib/launchpad/proof.ts | 11 +- app/src/lib/profiles/auth.ts | 56 +++ app/src/lib/profiles/http.ts | 18 + app/src/lib/profiles/names-client.ts | 87 ++++ app/src/lib/profiles/profiles.test.ts | 161 +++++++ app/src/lib/profiles/server.ts | 451 ++++++++++++++++++ app/src/lib/profiles/stats.ts | 16 + app/src/lib/profiles/validate.ts | 107 +++++ app/src/lib/profiles/xFetch.ts | 92 ++++ app/src/lib/profiles/xpost.ts | 145 ++++++ 42 files changed, 2354 insertions(+), 37 deletions(-) create mode 100644 app/src/app/api/profile/admin/route.ts create mode 100644 app/src/app/api/profile/check/route.ts create mode 100644 app/src/app/api/profile/delete/route.ts create mode 100644 app/src/app/api/profile/names/route.ts create mode 100644 app/src/app/api/profile/route.ts create mode 100644 app/src/app/api/profile/x/route.ts create mode 100644 app/src/app/u/[username]/opengraph-image.tsx create mode 100644 app/src/app/u/[username]/page.tsx create mode 100644 app/src/components/profile/NamesProvider.tsx create mode 100644 app/src/components/profile/ProfileIdentity.tsx create mode 100644 app/src/components/profile/ProfileQueue.tsx create mode 100644 app/src/components/profile/ProfileSheet.tsx create mode 100644 app/src/components/profile/Who.tsx create mode 100644 app/src/lib/launchpad/attribution.test.ts create mode 100644 app/src/lib/launchpad/attribution.ts create mode 100644 app/src/lib/profiles/auth.ts create mode 100644 app/src/lib/profiles/http.ts create mode 100644 app/src/lib/profiles/names-client.ts create mode 100644 app/src/lib/profiles/profiles.test.ts create mode 100644 app/src/lib/profiles/server.ts create mode 100644 app/src/lib/profiles/stats.ts create mode 100644 app/src/lib/profiles/validate.ts create mode 100644 app/src/lib/profiles/xFetch.ts create mode 100644 app/src/lib/profiles/xpost.ts diff --git a/app/db/schema.sql b/app/db/schema.sql index 00cc689c..215568cb 100644 --- a/app/db/schema.sql +++ b/app/db/schema.sql @@ -323,3 +323,70 @@ 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, + 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. 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')) +); +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 when the sender neither received nor sent the token in that transaction (ERC-4337 +-- smart wallets: tx.from is the bundler; relayed EIP-7702 calls: the relayer). Then the indexer credits the wallet +-- that actually took or paid the token (lib/launchpad/attribution.ts) and marks the row 'transfers'. +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; +-- 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/src/app/admin/page.tsx b/app/src/app/admin/page.tsx index df8dcfa9..21357e52 100644 --- a/app/src/app/admin/page.tsx +++ b/app/src/app/admin/page.tsx @@ -1,5 +1,6 @@ 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"; @@ -9,9 +10,10 @@ export default function AdminPage() {

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..a8579c62 --- /dev/null +++ b/app/src/app/api/profile/admin/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { isAddress } from "viem"; +import { rateLimited } from "@/lib/launchpad/editServer"; +import { adminWallets } from "@/lib/launchpad/postsServer"; +import { clientIp, readJson } from "@/lib/profiles/http"; +import { listProfilesForReview, moderateProfile } from "@/lib/profiles/server"; + +export const dynamic = "force-dynamic"; + +/** GET ?wallet= → X posts waiting for a person + the newest profiles. Everything listed is public anyway. */ +export async function GET(req: Request) { + const w = (new URL(req.url).searchParams.get("wallet") ?? "").toLowerCase(); + if (!isAddress(w) || !adminWallets().has(w)) return NextResponse.json({ error: "not an admin" }, { status: 403 }); + try { + return NextResponse.json(await listProfilesForReview(), { headers: { "cache-control": "no-store" } }); + } catch (err) { + console.error("[profile] review list failed:", err instanceof Error ? err.message : err); + return NextResponse.json({ error: "could not load" }, { status: 502 }); + } +} + +/** POST {action, target, reason?, chain, wallet, nonce, ts, signature} → one admin-signed moderation 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 { + 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 }); + } +} 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..3d070d3f --- /dev/null +++ b/app/src/app/api/profile/check/route.ts @@ -0,0 +1,28 @@ +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'`; + 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({ available: true }); + } +} 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..d9c9125d --- /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, postUrl} → checks the post against the wallet's open code. No signature: the code was only ever + * returned to the signer and is bound to the handle they signed, so a stranger submitting a link can at most finish + * someone's own verification for them. + */ +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 }); + 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..26492c5b 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"; @@ -90,10 +92,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 +145,7 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s }); return ( - <> +