diff --git a/app/.env.example b/app/.env.example index 8b82b5fa..5c2f0c4b 100644 --- a/app/.env.example +++ b/app/.env.example @@ -52,6 +52,9 @@ IMAGE_STORE=local # Earn (deposit idle USDC into the reviewed Morpho vault from /me, docs/earn.md) is OFF unless this is "on". On Fly: # `fly secrets set EARN=on` restarts the app, no deploy; any other value (or unsetting it) switches it off again. # EARN=on +# Boost (TWIG sent to 0x…dEaD for a place in the Boosted lane, docs/boost.md) is OFF unless this is "on". On Fly: +# `fly secrets set BOOST=on` restarts the app, no deploy; any other value (or unsetting it) switches it off again. +# BOOST=on # Wallets allowed to moderate posts (comma-separated, lowercase) ADMIN_WALLETS= diff --git a/app/db/concurrent-indexes.sql b/app/db/concurrent-indexes.sql index 5c39f688..e02ee3a2 100644 --- a/app/db/concurrent-indexes.sql +++ b/app/db/concurrent-indexes.sql @@ -15,3 +15,5 @@ CREATE INDEX CONCURRENTLY IF NOT EXISTS bb_launch_swaps_trader_idx ON bb_launch_ -- 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); +-- boosts (2026-10-15): a boost names its launch by position id; the lane joins on it +CREATE INDEX CONCURRENTLY IF NOT EXISTS bb_launches_chain_token_id_idx ON bb_launches (chain_id, token_id); diff --git a/app/db/schema.sql b/app/db/schema.sql index 9a3e5448..cb4814bb 100644 --- a/app/db/schema.sql +++ b/app/db/schema.sql @@ -429,3 +429,22 @@ ALTER TABLE bb_seasons ADD COLUMN IF NOT EXISTS computed_until timestamptz; -- 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; + +-- ── boosts (TWIG sent to 0x…dEaD for a place in the Boosted lane; lib/launchpad/boost.ts) ────────────────────────── +-- Raw facts from TWIG Transfer logs to 0x…dEaD on Base whose amount names a launch in its last 12 digits: the tag is +-- that launch's bb_launches.token_id (its position id). The launch is joined at read time, so a boost indexed before +-- its launch still counts once the launch is in. Never read by the Live sort, the Trending board or ranking.ts. +CREATE TABLE IF NOT EXISTS bb_boosts ( + chain_id integer NOT NULL, + tx_hash text NOT NULL, + log_index integer NOT NULL, + booster text NOT NULL, -- the Transfer's sender (a smart wallet itself, never its bundler) + amount numeric(78,0) NOT NULL, -- raw TWIG, the tag included + tag bigint NOT NULL, -- amount mod 10^12 + block_number bigint NOT NULL, + block_time timestamptz NOT NULL, + PRIMARY KEY (chain_id, tx_hash, log_index) +); +CREATE INDEX IF NOT EXISTS bb_boosts_time_idx ON bb_boosts (chain_id, block_time DESC); +CREATE INDEX IF NOT EXISTS bb_boosts_tag_idx ON bb_boosts (chain_id, tag); +ALTER TABLE bb_token_settings ADD COLUMN IF NOT EXISTS boost_hidden boolean NOT NULL DEFAULT false; -- admin: kept out of the Boosted lane diff --git a/app/src/app/(home)/page.tsx b/app/src/app/(home)/page.tsx index a9f2737f..cfb3a476 100644 --- a/app/src/app/(home)/page.tsx +++ b/app/src/app/(home)/page.tsx @@ -15,6 +15,8 @@ import { isFilter } from "@/lib/launchpad/search"; import { nowMs } from "@/lib/launchpad/time"; import { feedKey, inRiverWindow, riverCoverage } from "@/lib/launchpad/river"; import TrendingStrip from "@/components/launchpad/TrendingStrip"; +import BoostedLane from "@/components/launchpad/BoostedLane"; +import { boostLaneOrNull } from "@/lib/launchpad/boostServer"; import LaunchPill from "@/components/launchpad/LaunchPill"; import WhyFree from "@/components/launchpad/WhyFree"; import { MagicCard } from "@/components/vendor/magic-card"; @@ -36,12 +38,13 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s const usd = await ethUsd(); const layout = parseLayout((await cookies()).get(LAYOUT_COOKIE)?.value); const listOpts = { sort, window, chain, filter, limit: PAGE_SIZE, ethUsd: usd }; - const [page, feed, fresh, posts, fetchedTrending] = await Promise.all([ + const [page, feed, fresh, posts, fetchedTrending, boost] = await Promise.all([ listLaunchesPage(listOpts), getLaunchFeed(RIVER_SEED, usd), listLaunchesPage({ sort: "new", limit: JUST_LAUNCHED_SIZE, ethUsd: usd }), listFeed(30), isTrendingSource(listOpts) ? null : getTrending(usd), + boostLaneOrNull(usd), ]); const trending = await withBoardTape(fetchedTrending ?? trendingFrom(page.items)); // the default view's first page doubles as the strip's candidates; the tapes are the cards' recent trades const now = nowMs(); @@ -52,6 +55,8 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s {/* what is hot first (names and numbers to act on), then the last half hour as it happens, then every launch */} item.kind === "swap").map(feedKey)} /> + {/* paid and labelled, after the facts-only board and never inside it (boost.ts); nothing while BOOST is off */} + inRiverWindow(item, now))} recent={feed} serverNow={now} coveredSince={riverCoverage(feed, RIVER_SEED, now)} lastActivityAt={feed[0] ? new Date(feed[0].at).getTime() : 0} />
diff --git a/app/src/app/admin/page.tsx b/app/src/app/admin/page.tsx index 20f50eab..a4cd8e85 100644 --- a/app/src/app/admin/page.tsx +++ b/app/src/app/admin/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import AdminQueue from "@/components/launchpad/AdminQueue"; import ProfileQueue from "@/components/profile/ProfileQueue"; import PointsAdmin from "@/components/points/PointsAdmin"; +import BoostAdmin from "@/components/launchpad/BoostAdmin"; export const metadata: Metadata = { title: "Moderation", robots: { index: false, follow: false } }; export const dynamic = "force-dynamic"; @@ -12,11 +13,12 @@ export default function AdminPage() {

Moderation

-

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

+

Reported posts, profiles, season points and the Boosted lane. Every action is a signature from an admin wallet.

+
); } diff --git a/app/src/app/api/boost/mod/route.ts b/app/src/app/api/boost/mod/route.ts new file mode 100644 index 00000000..f5c41a26 --- /dev/null +++ b/app/src/app/api/boost/mod/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { rateLimited } from "@/lib/launchpad/editServer"; +import { clientIp, readJson } from "@/lib/profiles/http"; +import { moderateBoost } from "@/lib/launchpad/boostServer"; + +export const dynamic = "force-dynamic"; + +/** POST {action: hide|unhide, token, wallet, nonce, ts, signature}: an admin wallet keeps a Base token out of the Boosted lane, or lets it back. */ +export async function POST(req: Request) { + if (rateLimited(`boost-mod: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 r = await moderateBoost({ action: b.action, token: b.token, 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(r); +} diff --git a/app/src/app/api/boost/route.ts b/app/src/app/api/boost/route.ts new file mode 100644 index 00000000..235c2f60 --- /dev/null +++ b/app/src/app/api/boost/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { isAddress } from "viem"; +import { rateLimited } from "@/lib/launchpad/editServer"; +import { clientIp } from "@/lib/profiles/http"; +import { ethUsd } from "@/lib/launchpad/ethPrice"; +import { BOOST_CHAIN } from "@/lib/launchpad/boost"; +import { boostEnabled, getBoostLane, getTokenBoost } from "@/lib/launchpad/boostServer"; + +export const dynamic = "force-dynamic"; + +const NO_STORE = { "cache-control": "no-store" }; + +/** + * GET /api/boost?chain=base → the Boosted lane (`{ enabled, window_h, floor_twig, items }`), or `{ enabled: false }` + * while the BOOST switch is off. GET /api/boost?chain=base&token=0x… → one token's boost state (TWIG in the last 24 + * hours, its place in the lane, whether it is hidden). Boosts exist on Base only. Rate limited per IP. + */ +export async function GET(req: Request) { + if (rateLimited(`boost:ip:${clientIp(req)}`, 60)) return NextResponse.json({ error: "slow down" }, { status: 429 }); + const u = new URL(req.url); + const chain = u.searchParams.get("chain") ?? BOOST_CHAIN; + if (chain !== BOOST_CHAIN) return NextResponse.json({ error: "boosts are on Base only" }, { status: 400 }); + if (!boostEnabled()) return NextResponse.json({ enabled: false }, { headers: NO_STORE }); + const token = u.searchParams.get("token"); + try { + if (token !== null) { + if (!isAddress(token)) return NextResponse.json({ error: "bad token" }, { status: 400 }); + return NextResponse.json(await getTokenBoost(token), { headers: NO_STORE }); + } + return NextResponse.json(await getBoostLane(await ethUsd()), { headers: NO_STORE }); + } catch (err) { + console.error("[boost] read failed:", err instanceof Error ? err.message : err); + return NextResponse.json({ error: "could not load boosts" }, { status: 502 }); + } +} diff --git a/app/src/app/api/launch/live/route.ts b/app/src/app/api/launch/live/route.ts index 6f3b699d..cc32155a 100644 --- a/app/src/app/api/launch/live/route.ts +++ b/app/src/app/api/launch/live/route.ts @@ -7,10 +7,15 @@ import { ethUsd } from "@/lib/launchpad/ethPrice"; import { memo } from "@/lib/launchpad/memo"; import { listFeed } from "@/lib/launchpad/postsServer"; import { FEED_POSTS_LIMIT, feedPostsKey } from "@/lib/launchpad/posts-paging"; +import { boostEnabled, boostLaneOrNull } from "@/lib/launchpad/boostServer"; export const dynamic = "force-dynamic"; -/** One request for everything live on a page (feed, totals, optional list). Polled every ~5s by LiveProvider. */ +/** + * One request for everything live on a page (feed, totals, optional list). Polled every ~5s by LiveProvider. Every + * answer says whether BOOST is on (one boolean); the Boosted lane itself comes only with ?boost=1, which LiveProvider + * adds while a lane is on screen, so other pages never download its cards. + */ export async function GET(req: Request) { const u = new URL(req.url); const sort = parseSort(u.searchParams.get("sort"), null); @@ -21,21 +26,24 @@ export async function GET(req: Request) { const f = u.searchParams.get("filter"); const filter = isFilter(f) ? f : null; const limit = clampLimit(u.searchParams.get("limit"), 40); + const wantsBoost = u.searchParams.get("boost") === "1"; try { const usd = await ethUsd(); const listOpts = sort ? { sort, window, chain, filter, limit, offset: 0, ethUsd: usd } : null; // the home list's first page is the strip's candidate set: rank it instead of running the live query a second time; // any other view fetches the strip in the same batch as everything else const deriveTrending = listOpts !== null && isTrendingSource(listOpts); - const [feed, totals, page, posts, fetchedTrending] = await Promise.all([ + const [feed, totals, page, posts, fetchedTrending, boost] = await Promise.all([ memo("feed", 2_000, () => getLaunchFeed(24, usd)), memo("totals", 2_000, () => getLaunchTotals(usd)), listOpts ? memo(`list:${chain ?? "all"}:${sort}:${window}:${filter ?? "-"}:${limit}`, 2_000, () => listLaunchesPage(listOpts)) : Promise.resolve(null), memo(feedPostsKey(0), 2_000, () => listFeed(FEED_POSTS_LIMIT, 0)), // the same entry /api/posts?feed=1 serves deriveTrending ? Promise.resolve(null) : memo("trending", 2_000, () => getTrending(usd)), + // the switch is read on every poll, so turning BOOST off reaches open tabs within one tick; a failed read sends nothing + wantsBoost ? boostLaneOrNull(usd) : Promise.resolve(null), ]); const trending = fetchedTrending ?? trendingFrom(page?.items ?? []); - return NextResponse.json({ at: Date.now(), feed, totals, ethUsd: usd, sort, window, chain, filter, limit, has_more: page?.hasMore ?? null, launches: page?.items ?? null, posts, trending }, { headers: { "cache-control": "no-store" } }); + return NextResponse.json({ at: Date.now(), feed, totals, ethUsd: usd, sort, window, chain, filter, limit, has_more: page?.hasMore ?? null, launches: page?.items ?? null, posts, trending, boost_on: boostEnabled(), boost: boost ?? undefined }, { headers: { "cache-control": "no-store" } }); } catch (err) { console.error("[launch] live failed:", err instanceof Error ? err.message : err); return NextResponse.json({ error: "could not load live snapshot" }, { status: 502 }); diff --git a/app/src/app/api/launch/sync/route.ts b/app/src/app/api/launch/sync/route.ts index 9d2b060b..6b6b5ef0 100644 --- a/app/src/app/api/launch/sync/route.ts +++ b/app/src/app/api/launch/sync/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { DEFAULT_CHAIN, isChainKey } from "@/lib/chainPublic"; import { rateLimited } from "@/lib/launchpad/editServer"; import { applyLaunchTx, pollAll } from "@/lib/launchpad/indexer"; +import { forgetBoostLane } from "@/lib/launchpad/boostServer"; export const dynamic = "force-dynamic"; @@ -19,7 +20,10 @@ export async function POST(req: Request) { if (!/^0x[0-9a-fA-F]{64}$/.test(tx)) return NextResponse.json({ error: "bad tx" }, { status: 400 }); if (!isChainKey(chain)) return NextResponse.json({ error: "bad chain" }, { status: 400 }); try { - return NextResponse.json(await applyLaunchTx(chain, tx as `0x${string}`)); + const r = await applyLaunchTx(chain, tx as `0x${string}`); + // a boost just indexed: this machine's next lane read is fresh, so the booster sees their place at once + if (r.boosts) forgetBoostLane(); + return NextResponse.json(r); } catch (err) { return NextResponse.json({ error: err instanceof Error ? err.message : "sync failed" }, { status: 502 }); } diff --git a/app/src/app/globals.css b/app/src/app/globals.css index 14dbe376..4423f0bd 100644 --- a/app/src/app/globals.css +++ b/app/src/app/globals.css @@ -350,6 +350,15 @@ input[type="number"] { -moz-appearance: textfield; appearance: textfield; } .bb-card-sell { animation: bb-card-sell 2.2s ease-out 1; } @media (prefers-reduced-motion: reduce) { .bb-row-new, .bb-row-buy, .bb-row-sell, .bb-card-new, .bb-card-buy, .bb-card-sell { animation: none; } } +/* Boosted lane (BoostedLane.tsx): a card lights up once when a boost lands on it. Decorative: the amounts carry the + facts. The ring is inset, so the lane's clipped corners never cut it. Reduced motion keeps the colour, drops the + embers and the pop. */ +@keyframes bb-boost-ring { 0% { box-shadow: inset 0 0 0 2px var(--color-warm), inset 0 0 36px -10px var(--color-warm); } 100% { box-shadow: inset 0 0 0 2px transparent, inset 0 0 36px -10px transparent; } } +.bb-boost-ring { animation: bb-boost-ring 2.4s ease-out 1 both; } +@keyframes bb-ember { 0% { opacity: 0; transform: translate(0, 0) scale(1); } 12% { opacity: 1; } 100% { opacity: 0; transform: translate(var(--dx), var(--dy)) scale(0.3); } } +.bb-ember { animation: bb-ember 1.2s cubic-bezier(0.2, 0.7, 0.3, 1) 1 both; box-shadow: 0 0 6px var(--color-warm); } +@media (prefers-reduced-motion: reduce) { .bb-ember { display: none; } } + /* ── loading system: route progress bar, skeleton shimmer, spinner ─────────── */ /* scaleX rather than width: the bar animates on the compositor instead of re-running layout on every frame. transform-origin pins it to the left edge. */ diff --git a/app/src/app/layout.tsx b/app/src/app/layout.tsx index 0571eeb9..5f927e71 100644 --- a/app/src/app/layout.tsx +++ b/app/src/app/layout.tsx @@ -7,6 +7,7 @@ import TxToasts from "@/components/launchpad/TxToasts"; import LiveProvider from "@/components/launchpad/LiveProvider"; import { getLaunchFeed, getLaunchTotalsForRequest } from "@/lib/launchpad/queries"; import { ethUsd } from "@/lib/launchpad/ethPrice"; +import { boostEnabled } from "@/lib/launchpad/boostSwitch"; import { SITE_URL } from "@/lib/chainPublic"; import { BRAND, BRAND_DOMAIN, BRAND_GITHUB, BRAND_X, SITE_DESCRIPTION, SITE_TITLE, SOCIAL_DESCRIPTION } from "@/lib/brand"; import { siteJsonLdHtml, siteVerification } from "@/lib/seo"; @@ -49,7 +50,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo