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
-
+
diff --git a/app/src/app/llms.txt/route.ts b/app/src/app/llms.txt/route.ts
index b54d28aa..f0aacdb8 100644
--- a/app/src/app/llms.txt/route.ts
+++ b/app/src/app/llms.txt/route.ts
@@ -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";
@@ -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
diff --git a/app/src/app/rules/page.tsx b/app/src/app/rules/page.tsx
index fad98740..32edcf7a 100644
--- a/app/src/app/rules/page.tsx
+++ b/app/src/app/rules/page.tsx
@@ -10,6 +10,8 @@ import { CHAINS, CHAIN_KEYS, CHAIN_LABELS, explorerAddress } from "@/lib/chainPu
import { BRAND_GITHUB } from "@/lib/brand";
import LaunchMachine from "@/components/launchpad/LaunchMachine";
import { ChainLogo } from "@/components/launchpad/ChainLogo";
+import BoostRulesEntry from "@/components/launchpad/BoostRulesEntry";
+import { boostEnabled } from "@/lib/launchpad/boostServer";
import { RULES } from "@/lib/points/score";
export const metadata: Metadata = pageMetadata({ path: "/rules", title: "How it works", description: "What a launch does on-chain, what it costs (gas), and what can never happen to your liquidity." });
@@ -131,6 +133,7 @@ export default function RulesPage() {
How does the price move?
Price follows a single-sided Uniswap v4 curve: the first buyers get the most tokens per unit of the quote asset, and every buy moves the price up. Sells move it down.
There is no anti-snipe mechanism. Bots can buy in the first block like anyone else.
The launch form suggests a small first buy (about $25) so your token opens with a holder and a price; clear it and the launch stays free.
Why is my token under “quiet launches”?
The home page ranks by facts, not by launch order. A token is live once a wallet other than its launcher has traded it in the last 24 hours; trades in the launch block and the next three blocks (snipe bots) don't count, and neither do the launcher's own. Live tokens rank by outside wallets in the last hour, then today, then by their last outside trade. A wallet's newest launch gets its first hour on the board regardless; after that it sits under quiet launches, one row per wallet, until someone trades it.
Nothing is hidden: every launch stays in the New tab, in search and in the API. The only way up is a trade from an outside wallet.
+ {boostEnabled() ? : null}
What can change after launch?
Nothing on-chain can be edited after launch: not the name, not the fee, not the beneficiaries. Metadata (image, description, links) is stored by this site and can change; the token itself cannot.
What is a TWIG- or GITLAWB-quoted pool?
TWIG is Twigpine's token on Base: a 1:1 wrapper of GITLAWB. Anyone can wrap GITLAWB into TWIG, or unwrap it back, at any time with no fee, and every TWIG is backed by one GITLAWB held in its contract, which has no owner and no transfer restrictions. It is the quote the Base form offers: pick it and buyers pay in TWIG; any trading fee is paid in TWIG to the beneficiaries named at launch (by their shares) when it is collected, and a launch that names none has its TWIG fees burned when they are collected.
GITLAWB is Twigpine's token on Base, bridged 1:1 to Robinhood Chain over LayerZero (one supply, two chains): an ordinary ERC-20 with no transfer restrictions and no issuer switch. The Robinhood Chain form offers it; on Base, pools already paired with GITLAWB keep their badge and price. Fees work the same way: paid in GITLAWB when collected, and a launch that names no beneficiary has its GITLAWB fees burned when they are collected.
Dollar figures for both come from the Uniswap v4 WETH/GITLAWB pool price on Base, cross-checked against a 30-minute on-chain average, and ETH/USD; one TWIG counts as one GITLAWB, since it unwraps to one. They are for display and sorting only; nothing on-chain depends on them.
What should I know about stock-quoted pools?
Stock-quoted pools use tokenized stocks as the quote asset: Coinbase tokenized stocks (B20) on Base, recognised here only from Coinbase's official list and priced in dollars where Chainlink publishes an on-chain feed (the others show no dollar price); Robinhood Stock Tokens on Robinhood Chain, recognised only from Robinhood's own registry.
Both are securities issued by third parties under Regulation S and are not offered to US persons. Coinbase's also exclude the UK, Canada, Australia, Singapore and Switzerland. Their issuers can pause transfers or freeze wallets in restricted jurisdictions. Holding or trading them is subject to the issuer's terms, not ours.
diff --git a/app/src/app/t/[chain]/[token]/page.tsx b/app/src/app/t/[chain]/[token]/page.tsx
index 8df1728c..d40eebba 100644
--- a/app/src/app/t/[chain]/[token]/page.tsx
+++ b/app/src/app/t/[chain]/[token]/page.tsx
@@ -50,6 +50,11 @@ import WatchButton from "@/components/launchpad/WatchButton";
import { BorderBeam } from "@/components/vendor/border-beam";
import { Banner } from "@/components/launchpad/LaunchCard";
import { ChainLogo } from "@/components/launchpad/ChainLogo";
+import BoostPanel from "@/components/launchpad/BoostPanel";
+import { boostEnabled, getTokenBoost, type TokenBoost } from "@/lib/launchpad/boostServer";
+import { BOOST_CHAIN } from "@/lib/launchpad/boost";
+import { gitlawbUsd } from "@/lib/launchpad/gitlawbServer";
+import { twigUsdFromGitlawb } from "@/lib/launchpad/twig";
import type { ProofKey } from "@/lib/launchpad/proof";
/** Where GITLAWB lives, as said beside a GITLAWB-quoted pool. */
@@ -76,7 +81,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 }> }) {
+export default async function TokenPage({ params, searchParams }: { params: Promise<{ chain: string; token: string }>; searchParams: Promise<{ boost?: string }> }) {
const { chain, token } = await params;
if (!isChainKey(chain) || !isAddress(token)) notFound();
const usd = await ethUsd();
@@ -93,7 +98,16 @@ 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)]);
+ // Boost (Base only, while the switch is on): the token's line and TWIG's price for the dialog; a failed read shows no panel
+ const boostable = chain === BOOST_CHAIN && boostEnabled();
+ const [swaps, holders, tint, boost, twigUsd] = 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),
+ boostable ? getTokenBoost(l.token).catch((): TokenBoost | null => null) : null,
+ boostable ? gitlawbUsd().then(twigUsdFromGitlawb).catch(() => null) : null,
+ ]);
+ const boostOpen = (await searchParams).boost === "1";
// 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));
// a failed lookup seeds nothing (the browser then asks itself); only an answer says "no profile" for a wallet
@@ -238,6 +252,8 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s
{/* the token at a glance beside the trade box, as other launchpads keep it; the full record is in the About tab */}
+ {/* Boost (Base, while the switch is on): under the About card, which stays right under the trade box */}
+ {boost ? : null}
diff --git a/app/src/components/CommandPalette.tsx b/app/src/components/CommandPalette.tsx
index 4d08d0a4..02768f65 100644
--- a/app/src/components/CommandPalette.tsx
+++ b/app/src/components/CommandPalette.tsx
@@ -13,6 +13,7 @@ import { setPendingToken } from "@/lib/launchpad/token-transition";
import { buildPaletteGroups, isSearchableQuery, paletteTokenFrom, type PaletteGroup, type PaletteItem, type PaletteToken } from "@/lib/command-palette";
import { useOpenBridge } from "./bridge/BridgeProvider";
import { useEarnInfo } from "./earn/earnInfo";
+import { useBoostOn } from "./launchpad/useBoostOn";
type LaunchesResponse = { launches?: Parameters[0][] };
@@ -67,7 +68,9 @@ export default function CommandPalette({ open, onOpenChange }: { open: boolean;
const theme = resolvedTheme === "dark" ? "dark" : "light";
// the Earn entry exists only while the feature is switched on; asked once, on the first open
const earn = useEarnInfo(open)?.enabled === true;
- const groups = useMemo(() => buildPaletteGroups({ query, tokens: current?.tokens ?? [], trending: trending ?? [], theme, earn }), [query, current, trending, theme, earn]);
+ // the Boosted entry follows the live poll's switch: no request of its own
+ const boost = useBoostOn();
+ const groups = useMemo(() => buildPaletteGroups({ query, tokens: current?.tokens ?? [], trending: trending ?? [], theme, earn, boost }), [query, current, trending, theme, earn, boost]);
const status = searching ? "Searching tokens…" : current?.failed ? "Token search is unavailable right now. Pages and actions still work." : "";
const close = () => {
diff --git a/app/src/components/launchpad/BoostAdmin.tsx b/app/src/components/launchpad/BoostAdmin.tsx
new file mode 100644
index 00000000..6bf2732b
--- /dev/null
+++ b/app/src/components/launchpad/BoostAdmin.tsx
@@ -0,0 +1,90 @@
+"use client";
+
+import Link from "next/link";
+import { useCallback, useEffect, useState } from "react";
+import { useConfig } from "wagmi";
+import { getWalletClient } from "wagmi/actions";
+import { isAddress } from "viem";
+import { useHydratedAccount } from "@/lib/useHydratedAccount";
+import { btn, card } from "@/components/ui";
+import type { BoostLane } from "@/lib/launchpad/boostServer";
+import { BOOST_CHAIN, buildBoostModMessage, fmtBoostTwig, type BoostModAction } from "@/lib/launchpad/boost";
+import { CHAINS } from "@/lib/chainPublic";
+import { nowMs } from "@/lib/launchpad/time";
+import { friendlyError } from "@/lib/errors";
+import { nonce } from "@/lib/nonce";
+
+/**
+ * The Boosted lane for moderators: what is in it now, Hide on each card, and Hide / Unhide by address (a hidden token is
+ * no longer listed anywhere, so showing it again takes its address). Each action is an admin-wallet signature. Hiding
+ * never refunds: the TWIG was burned on-chain.
+ */
+export default function BoostAdmin() {
+ const { address } = useHydratedAccount();
+ const config = useConfig();
+ const [lane, setLane] = useState(null);
+ const [target, setTarget] = useState("");
+ const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
+ // a failed or unreadable answer (a proxy's HTML page) keeps the last list on screen; it never throws from the effect
+ const load = useCallback(async () => {
+ try {
+ const res = await fetch(`/api/boost?chain=${BOOST_CHAIN}`, { cache: "no-store" });
+ if (res.ok) setLane((await res.json()) as BoostLane);
+ } catch {
+ // keep the last lane
+ }
+ }, []);
+ useEffect(() => {
+ const id = setTimeout(() => void load(), 0);
+ return () => clearTimeout(id);
+ }, [load]);
+
+ async function act(token: string, action: BoostModAction) {
+ if (!address) return;
+ try {
+ const n = nonce();
+ const ts = nowMs();
+ const wallet = await getWalletClient(config, { chainId: CHAINS[BOOST_CHAIN].id });
+ const signature = await wallet.signMessage({ message: buildBoostModMessage({ action, token, wallet: address, nonce: n, ts }) });
+ const res = await fetch("/api/boost/mod", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action, token, wallet: address, nonce: n, ts, signature }) });
+ // an error page that is not JSON (a 502 from the proxy) still reports the HTTP failure, not a parse error
+ const d = (await res.json().catch(() => ({}))) as { error?: string };
+ if (!res.ok) throw new Error(d.error ?? `request failed (HTTP ${res.status})`);
+ setMsg({ ok: true, text: `${action === "hide" ? "Hidden" : "Shown again"}: ${token}` });
+ // a hidden card leaves the list at once; the next read (fresh on the machine that took the write) confirms it
+ if (action === "hide") setLane((l) => (l?.enabled ? { ...l, items: l.items.filter((b) => b.token !== token.toLowerCase()) } : l));
+ await load();
+ } catch (e) {
+ setMsg({ ok: false, text: friendlyError(e) });
+ }
+ }
+
+ if (lane && !lane.enabled) return null;
+ return (
+
+
: null}
+
+ );
+}
diff --git a/app/src/components/launchpad/BoostDialog.tsx b/app/src/components/launchpad/BoostDialog.tsx
new file mode 100644
index 00000000..0d622e96
--- /dev/null
+++ b/app/src/components/launchpad/BoostDialog.tsx
@@ -0,0 +1,245 @@
+"use client";
+
+import Link from "next/link";
+import { useMemo, useRef, useState } from "react";
+import { useConfig, useReadContract, useSwitchChain } from "wagmi";
+import { getPublicClient, getWalletClient } from "wagmi/actions";
+import type { Hex } from "viem";
+import { ArrowUpRight } from "lucide-react";
+import Sheet from "@/components/Sheet";
+import ConnectWallet from "@/components/ConnectWallet";
+import { btn } from "@/components/ui";
+import { Spinner } from "@/components/Skeleton";
+import { useHydratedAccount } from "@/lib/useHydratedAccount";
+import { ERC20_MIN_ABI, ERC20_TRANSFER_ABI } from "@/lib/launchpad/abi";
+import { DEAD } from "@/lib/launchpad/config";
+import { TWIG_WRAP_URL } from "@/lib/launchpad/twig";
+import { fmtUsd } from "@/lib/launchpad/math";
+import { friendlyError } from "@/lib/errors";
+import { BUILDER_DATA_SUFFIX, CHAINS, explorerTx } from "@/lib/chainPublic";
+import {
+ BOOST_CHAIN,
+ BOOST_COPY,
+ BOOST_FLOOR_TWIG,
+ BOOST_PRESETS,
+ BOOST_TOKEN,
+ boostAmount,
+ boostAmountProblem,
+ boostConfirmLabel,
+ boostShowsNow,
+ fmtBoostTwig,
+ type LaneEntry,
+ groupWhole,
+ isLaunchTag,
+ maxBoostWhole,
+ parseBoostWhole,
+} from "@/lib/launchpad/boost";
+import { toast } from "./TxToasts";
+import { TwigMark } from "./TwigBadge";
+
+/** A boost the wallet has sent whose outcome is not known yet; the panel keeps it while the dialog is closed. */
+export type PendingBoost = { hash: Hex; whole: bigint };
+type Phase =
+ | { k: "idle" }
+ | { k: "preparing" }
+ | { k: "signing" }
+ | { k: "sent"; hash: Hex; whole: bigint }
+ | { k: "unconfirmed"; hash: Hex; whole: bigint; checking: boolean; note: string | null }
+ | { k: "done"; hash: Hex; whole: bigint; shows: boolean }
+ | { k: "error"; message: string };
+
+const BASE = CHAINS[BOOST_CHAIN];
+
+/**
+ * Send TWIG to 0x…dEaD for a token's place in the Boosted lane (boost.ts): one plain TWIG.transfer whose amount ends in
+ * the launch's position id. No approval, nothing to sign but the transfer. Whole TWIG only; the floor and the balance
+ * (tag included) are checked before the button wakes, and the transfer is simulated before the wallet is asked.
+ * Sent means sent: once the wallet returns a hash, only a receipt ends the action. A receipt that cannot be read leaves
+ * it unconfirmed and locked, with "Check again", so a slow node never invites a second burn; the panel holds the hash
+ * (`pending`) so closing and reopening the dialog keeps the lock.
+ */
+export default function BoostDialog({ token, tokenId, symbol, name, twigUsd, current, entry, place, pending, onPending, onClose, onBoosted }: { token: string; tokenId: number; symbol: string; name: string; twigUsd: number | null; current: bigint; entry: LaneEntry; place: number | null; pending: PendingBoost | null; onPending: (p: PendingBoost | null) => void; onClose: () => void; onBoosted?: () => void }) {
+ const config = useConfig();
+ const { address, isConnected, chainId } = useHydratedAccount();
+ const { switchChainAsync } = useSwitchChain();
+ const [amount, setAmount] = useState(groupWhole(BOOST_FLOOR_TWIG));
+ const [phase, setPhase] = useState(pending ? { k: "unconfirmed", ...pending, checking: false, note: null } : { k: "idle" });
+ const lock = useRef(false);
+ const tag = BigInt(tokenId);
+ const tagOk = isLaunchTag(tag);
+
+ const bal = useReadContract({ address: BOOST_TOKEN as `0x${string}`, abi: ERC20_MIN_ABI, functionName: "balanceOf", args: address ? [address] : undefined, chainId: BASE.id, query: { enabled: Boolean(address), refetchInterval: 15_000 } });
+ const balance = bal.data as bigint | undefined;
+ const whole = parseBoostWhole(amount);
+ const problem = tagOk ? boostAmountProblem(whole, balance, tag) : "This launch cannot be boosted.";
+ const raw = useMemo(() => (whole !== null && problem === null ? boostAmount(whole, tag) : null), [whole, problem, tag]);
+ const max = balance !== undefined && tagOk ? maxBoostWhole(balance, tag) : null;
+ const busy = phase.k === "preparing" || phase.k === "signing" || phase.k === "sent" || phase.k === "unconfirmed";
+ const usd = whole !== null && twigUsd ? fmtUsd(Number(whole) * twigUsd) : null;
+ // a full lane shows 5 tokens: say before the send whether this amount gets this one in, and what would
+ const inLane = place !== null;
+ const shows = raw === null || boostShowsNow(entry, current, raw, inLane);
+ /** Whether a sent boost of `w` whole TWIG brings the token into the lane: one rule for the done screen, however the receipt arrived. */
+ const showsAfter = (w: bigint): boolean => !tagOk || boostShowsNow(entry, current, boostAmount(w, tag), inLane);
+ const neededWhole = !inLane && entry.full && entry.cutoff !== null ? (() => {
+ const gap = BigInt(entry.cutoff) - current;
+ return gap > 0n ? (gap + 10n ** 18n - 1n) / 10n ** 18n : 0n;
+ })() : null;
+
+ async function send() {
+ if (!address || raw === null || whole === null || busy || lock.current) return;
+ lock.current = true; // before the first await
+ setPhase({ k: "preparing" });
+ try {
+ if (chainId !== BASE.id) await switchChainAsync({ chainId: BASE.id });
+ const pub = getPublicClient(config, { chainId: BASE.id })!;
+ const wallet = await getWalletClient(config, { chainId: BASE.id });
+ const { request } = await pub.simulateContract({ address: BOOST_TOKEN as `0x${string}`, abi: ERC20_TRANSFER_ABI, functionName: "transfer", args: [DEAD, raw], account: address, dataSuffix: BUILDER_DATA_SUFFIX });
+ setPhase({ k: "signing" });
+ const hash = await wallet.writeContract(request);
+ onPending({ hash, whole });
+ setPhase({ k: "sent", hash, whole });
+ let rc;
+ try {
+ rc = await pub.waitForTransactionReceipt({ hash });
+ } catch {
+ // sent, outcome unknown: never an error that would let the same TWIG be sent twice
+ setPhase({ k: "unconfirmed", hash, whole, checking: false, note: null });
+ return;
+ }
+ await settle(hash, whole, rc.status === "success", showsAfter(whole));
+ } catch (err) {
+ setPhase({ k: "error", message: friendlyError(err) });
+ } finally {
+ lock.current = false;
+ }
+ }
+
+ /** A final answer for a sent boost: indexed and done, or reverted (sending again is then safe). */
+ async function settle(hash: Hex, whole: bigint, ok: boolean, shows: boolean) {
+ onPending(null);
+ if (!ok) {
+ setPhase({ k: "error", message: "The transfer reverted on-chain. No TWIG was sent; you can try again." });
+ return;
+ }
+ // index it now rather than at the next poll: the lane and this page show it straight away
+ await fetch(`/api/launch/sync?chain=${BOOST_CHAIN}&tx=${hash}`, { method: "POST" }).catch(() => {});
+ setPhase({ k: "done", hash, whole, shows });
+ toast({ kind: "info", title: `${symbol} is boosted`, sub: `${groupWhole(whole)} TWIG sent to 0x…dEaD`, chain: BOOST_CHAIN, token, symbol });
+ void bal.refetch();
+ onBoosted?.();
+ }
+
+ async function checkAgain() {
+ if (phase.k !== "unconfirmed" || phase.checking) return;
+ const { hash, whole } = phase;
+ setPhase({ ...phase, checking: true, note: null });
+ try {
+ const rc = await getPublicClient(config, { chainId: BASE.id })!.getTransactionReceipt({ hash });
+ await settle(hash, whole, rc.status === "success", showsAfter(whole));
+ } catch {
+ setPhase({ k: "unconfirmed", hash, whole, checking: false, note: "Not confirmed yet. Check your wallet's activity before sending anything again." });
+ }
+ }
+
+ return (
+
+ {phase.k === "done" ? (
+
+
{place !== null ? `${name} is #${place} in the Boosted lane.` : phase.shows ? `${name} is in the Boosted lane.` : `${name} is not in the lane yet: the 5th place still holds more.`}
+
{fmtBoostTwig(boostAmount(phase.whole, tag))} went to 0x…dEaD. It counts for the next 24 hours and adds up with any other boost on {symbol}; the lane shows the 5 largest totals.
Your wallet sent {groupWhole(phase.whole)} TWIG, but Base has not confirmed it to this page yet. Sending is locked until it does, so the same boost is never paid twice.
+ )}
+
+ );
+}
diff --git a/app/src/components/launchpad/BoostPanel.tsx b/app/src/components/launchpad/BoostPanel.tsx
new file mode 100644
index 00000000..207e29cf
--- /dev/null
+++ b/app/src/components/launchpad/BoostPanel.tsx
@@ -0,0 +1,80 @@
+"use client";
+
+import dynamic from "next/dynamic";
+import { useCallback, useEffect, useRef, useState } from "react";
+import { ArrowUpRight } from "lucide-react";
+import { btn } from "@/components/ui";
+import { useLive } from "./LiveProvider";
+import { TwigMark } from "./TwigBadge";
+import type { TokenBoost } from "@/lib/launchpad/boostServer";
+import { BOOST_CHAIN, BOOST_COPY, boostTimeLeft, fmtBoostTwig } from "@/lib/launchpad/boost";
+import { explorerTx } from "@/lib/chainPublic";
+import type { PendingBoost } from "./BoostDialog";
+
+// the dialog (wallet reads, the send flow) loads on first use, not with the page
+const BoostDialog = dynamic(() => import("./BoostDialog"), { ssr: false });
+
+/**
+ * A Base token's boost line on its page: whether it is in the Boosted lane, with how much TWIG and for how long, and
+ * the button that opens the dialog. Nothing while BOOST is off. A token an admin hid says so and offers no button, so
+ * nobody sends TWIG for a place it cannot get. `autoOpen` comes from /me's "Boost" link (?boost=1).
+ */
+export default function BoostPanel({ token, tokenId, symbol, name, initial, twigUsd, serverNow, autoOpen = false }: { token: string; tokenId: number; symbol: string; name: string; initial: TokenBoost; twigUsd: number | null; serverNow: number; autoOpen?: boolean }) {
+ const { refresh } = useLive();
+ const [state, setState] = useState(initial);
+ const [open, setOpen] = useState(autoOpen && initial.enabled && !initial.hidden);
+ const [now, setNow] = useState(serverNow);
+ // a sent boost whose receipt is not read yet: kept here so a closed and reopened dialog stays locked on it
+ const [pending, setPending] = useState(null);
+ useEffect(() => {
+ const clock = setInterval(() => setNow(Date.now()), 30_000);
+ return () => clearInterval(clock);
+ }, []);
+ const again = useRef | null>(null);
+ useEffect(() => () => {
+ if (again.current) clearTimeout(again.current);
+ }, []);
+ const read = useCallback(() => {
+ fetch(`/api/boost?chain=${BOOST_CHAIN}&token=${token}`, { cache: "no-store" })
+ .then(async (r) => (r.ok ? setState((await r.json()) as TokenBoost) : undefined))
+ .catch(() => {});
+ }, [token]);
+ // after a boost: read now (the machine that indexed it answers fresh) and once more after the lane's 5 s cache, for
+ // a request that lands on the other machine
+ const reload = useCallback(() => {
+ read();
+ if (again.current) clearTimeout(again.current);
+ again.current = setTimeout(read, 6_000);
+ refresh(); // the hero's TWIG burned
+ }, [read, refresh]);
+
+ if (!state.enabled) return null;
+ const left = state.last_at ? boostTimeLeft(state.last_at, now) : null;
+ const active = left !== null && BigInt(state.twig) > 0n;
+ return (
+
+
+
Boost
+ {active && state.place ? #{state.place} in the lane : null}
+
+ {state.hidden ? (
+
{BOOST_COPY.hidden}
+ ) : (
+ <>
+
+ {active ? (
+ <>Boosted with {fmtBoostTwig(state.twig)} in the last 24 hours · {left}.>
+ ) : (
+ <>Not boosted. Sending TWIG to 0x…dEaD puts {symbol} in the Boosted lane for 24 hours.>
+ )}
+
+ >
+ )}
+ {open ? setOpen(false)} onBoosted={reload} /> : null}
+
+ );
+}
diff --git a/app/src/components/launchpad/BoostRulesEntry.tsx b/app/src/components/launchpad/BoostRulesEntry.tsx
new file mode 100644
index 00000000..44ee48f0
--- /dev/null
+++ b/app/src/components/launchpad/BoostRulesEntry.tsx
@@ -0,0 +1,16 @@
+import { ChevronDown } from "lucide-react";
+import { BOOST_COPY } from "@/lib/launchpad/boost";
+
+/** The rules page's Boost question; the page renders it only while the switch is on (boostEnabled, read per request). */
+export default function BoostRulesEntry() {
+ return (
+
+ What is the Boosted lane?
+
+
It is a separate, labelled row on the home page and the Base page. A place in it is paid for by sending TWIG to 0x…dEaD: the TWIG is gone, and nothing is paid to openlaunch or Twigpine. It is not part of the ranking above: the Live sort and the Trending board count outside wallets only, and no boost changes them.
+
{BOOST_COPY.what} {BOOST_COPY.how}
+
{BOOST_COPY.tag} {BOOST_COPY.final}
+
+
+ );
+}
diff --git a/app/src/components/launchpad/BoostedLane.tsx b/app/src/components/launchpad/BoostedLane.tsx
new file mode 100644
index 00000000..dde12f7d
--- /dev/null
+++ b/app/src/components/launchpad/BoostedLane.tsx
@@ -0,0 +1,168 @@
+"use client";
+
+import Link from "next/link";
+import { useEffect, useRef, useState, type CSSProperties } from "react";
+import { motion, useReducedMotion } from "motion/react";
+import { ArrowUpRight, ChevronDown } from "lucide-react";
+import TokenAvatar from "./TokenAvatar";
+import { TwigMark } from "./TwigBadge";
+import { useLive } from "./LiveProvider";
+import LiveNumber from "@/components/LiveNumber";
+import type { BoostLane } from "@/lib/launchpad/boostServer";
+import { BOOST_COPY, BOOST_LANE_SIZE, boostFuse, boostIgnitions, boostTimeLeft, boostWholeOf } from "@/lib/launchpad/boost";
+import { capDisplay } from "@/lib/launchpad/market-cap";
+import { explorerTx } from "@/lib/chainPublic";
+import { setPendingToken } from "@/lib/launchpad/token-transition";
+
+/** The rolling TWIG figure: "1.2M", "300K", like fmtBoostTwig (Intl rounds 999,999 up to "1M" by itself). */
+const TWIG_FORMAT = { notation: "compact", maximumFractionDigits: 2 } as const;
+
+/** Sparks that rise from the amount when a boost lands: offsets, delays and size. Decorative, a fixed set. */
+const EMBERS: readonly { dx: number; dy: number; delay: number; size: number; ink?: boolean }[] = [
+ { dx: -6, dy: -24, delay: 0, size: 4 },
+ { dx: 3, dy: -32, delay: 90, size: 3, ink: true },
+ { dx: 10, dy: -20, delay: 160, size: 3 },
+ { dx: -1, dy: -28, delay: 240, size: 4, ink: true },
+ { dx: 15, dy: -16, delay: 320, size: 3 },
+];
+/** An ignition is drawn for this long; older entries are dropped when the next one is recorded. */
+const IGNITE_KEEP_MS = 10_000;
+
+/**
+ * The Boosted lane: tokens someone sent TWIG to 0x…dEaD for, most TWIG in the last 24 hours first (boost.ts). A paid,
+ * labelled surface of its own, never mixed into the Live sort or the Trending board. Renders nothing while the BOOST
+ * switch is off. It asks the shared live poll for the lane while it is on screen (wantBoost); a poll without it is no
+ * news, so the cards stay as they are.
+ *
+ * Motion follows the board above it: nothing moves on its own. Each card carries a fuse (the share of its 24 hours
+ * left, stepped with the card's clock), and a card lights up once when a fresh boost lands on it: a warm ring, the
+ * amount rolling up, a few embers. Places change with a short slide.
+ */
+export default function BoostedLane({ initial, serverNow }: { initial: BoostLane; serverNow: number }) {
+ const { subscribe, wantBoost } = useLive();
+ const reduced = useReducedMotion();
+ const [lane, setLane] = useState(initial);
+ const [now, setNow] = useState(serverNow);
+ // what each card held at the last snapshot: a token with more TWIG than this, freshly boosted, lights up
+ const prev = useRef