Skip to content
Merged
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
3 changes: 3 additions & 0 deletions app/.env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# Copy to .env.local for local dev. Never commit real values.

# Public site URL (used in metadata, image URLs, share cards)
Expand Down Expand Up @@ -52,6 +52,9 @@
# 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=
2 changes: 2 additions & 0 deletions app/db/concurrent-indexes.sql
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
-- 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
Expand All @@ -15,3 +15,5 @@
-- 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);
19 changes: 19 additions & 0 deletions app/db/schema.sql
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
-- basebid.lol · free token launchpad on Base.
--
-- Applied by scripts/migrate.mjs (Fly release_command) against the DEDICATED
Expand Down Expand Up @@ -429,3 +429,22 @@
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
7 changes: 6 additions & 1 deletion app/src/app/(home)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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();
Expand All @@ -52,6 +55,8 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s
<LaunchHero configured={LAUNCHPAD_CONFIGURED} />
{/* what is hot first (names and numbers to act on), then the last half hour as it happens, then every launch */}
<TrendingStrip initial={trending} serverNow={now} seenKeys={feed.filter((item) => item.kind === "swap").map(feedKey)} />
{/* paid and labelled, after the facts-only board and never inside it (boost.ts); nothing while BOOST is off */}
<BoostedLane initial={boost ?? { enabled: false }} serverNow={now} />
<LiveRiver initial={feed.filter((item) => inRiverWindow(item, now))} recent={feed} serverNow={now} coveredSince={riverCoverage(feed, RIVER_SEED, now)} lastActivityAt={feed[0] ? new Date(feed[0].at).getTime() : 0} />
<div className="grid items-start gap-x-8 gap-y-6 xl:grid-cols-[minmax(0,1fr)_19rem]">
<MagicCard className="min-w-0 rounded-2xl"><LaunchList frame="rounded-[inherit]" initial={page.items} initialHasMore={page.hasMore} initialSort={sort} initialWindow={window} initialChain={chain} initialFilter={filter} initialView={sp.view === "watchlist" ? "watchlist" : "market"} initialLayout={layout} hasDb={dbConfigured()} serverNow={now} /></MagicCard>
Expand Down
4 changes: 3 additions & 1 deletion app/src/app/admin/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -12,11 +13,12 @@ export default function AdminPage() {
<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, profiles and season points. Every action is a signature from an admin wallet.</p>
<p className="mt-2 text-base text-body">Reported posts, profiles, season points and the Boosted lane. Every action is a signature from an admin wallet.</p>
</header>
<AdminQueue />
<ProfileQueue />
<PointsAdmin />
<BoostAdmin />
</main>
);
}
16 changes: 16 additions & 0 deletions app/src/app/api/boost/mod/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
35 changes: 35 additions & 0 deletions app/src/app/api/boost/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 { 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 });
}
}
14 changes: 11 additions & 3 deletions app/src/app/api/launch/live/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 });
Expand Down
6 changes: 5 additions & 1 deletion app/src/app/api/launch/sync/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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 });
}
Expand Down
9 changes: 9 additions & 0 deletions app/src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
3 changes: 2 additions & 1 deletion app/src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -49,7 +50,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: siteJsonLd }} />
<ThemeProvider nonce={nonce}>
<Web3Provider>
<LiveProvider initial={{ at: 0, feed, totals, ethUsd: usd }}>
<LiveProvider initial={{ at: 0, feed, totals, ethUsd: usd, boost_on: boostEnabled() }}>
<Suspense fallback={null}>
<RouteProgress />
</Suspense>
Expand Down
19 changes: 19 additions & 0 deletions app/src/app/llms.txt/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,24 @@
import { SITE_URL } from "@/lib/chainPublic";
import { launchpad } from "@/lib/launchpad/config";
import { BRAND_DOMAIN, BRAND_GITHUB } from "@/lib/brand";
import { boostEnabled } from "@/lib/launchpad/boostServer";
import { BOOST_FLOOR_TWIG, BOOST_TOKEN, BOOST_WINDOW_H } from "@/lib/launchpad/boost";

/** The Boost section, only while the switch is on (boost.ts holds the rule). */
function boostSection(): string {
if (!boostEnabled()) return "";
return `
## Boost (Base, optional, paid in TWIG)
A separate "Boosted" lane on the home page: the 5 tokens with the most TWIG sent to 0x000000000000000000000000000000000000dEaD for them in the last ${BOOST_WINDOW_H}h.
It is not part of the ranking: sort=live and the Trending board count outside wallets only. Nothing is paid to openlaunch.
To boost a launch: TWIG (${BOOST_TOKEN}).transfer(0x…dEaD, amount) with amount = whole * 10^18 + token_id
(whole = a count of whole TWIG, at least ${BOOST_FLOOR_TWIG}; token_id = the launch's position id, the list API's token_id field,
greater than 0 and less than 10^11). The last 12 digits name the launch; a transfer outside these bounds is not a boost.
Then POST ${SITE_URL}/api/launch/sync?chain=base&tx=0x… to show it at once. Burns are final.
- GET ${SITE_URL}/api/boost?chain=base (the lane: twig, boosts, last_at, expires_at, launch)
- GET ${SITE_URL}/api/boost?chain=base&token=0x… (one token: twig in the window, place, hidden)
`;
}

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -67,6 +85,7 @@ Optional metadata: POST ${SITE_URL}/api/launch/meta {chain, launcher, salt, name
Recommended: buy a little right after launch (a Universal Router swap on the new pool; the site's form suggests ≈ $25). A token with no
holder and no price move reads as dead on screeners and sits under "quiet launches" on the home page until an outside wallet trades it.

${boostSection()}
## API
- GET ${SITE_URL}/api/launch/list?chain=base|robinhood|arc&sort=live|new|mcap|volume|gainers|holders&window=1h|24h|all&limit=50
sort=live (the home page default; "trending" is an alias): tokens an outside wallet traded in the last 24h first, ranked by outside
Expand Down
Loading
Loading