From 37b2aa3eea1e3ad3091ae3337239fbe7d42fb6b5 Mon Sep 17 00:00:00 2001
From: Kevin Codex
Date: Fri, 16 Oct 2026 14:59:23 +0800
Subject: [PATCH 1/3] feat(boost): invite boosts from the lane: Boost a token,
open spots, entry line, booster credit
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- "Boost a token" in the lane header on every screen size; each open place is a button from 1024px, one
"+ N open spots · Boost a token" row below that. All open the picker (BoostPicker, loaded on first use): the
connected wallet's own Base launches first, a Base search, or trending on Base. Picking goes to
/t/base/?boost=1#boost, where the dialog opens with every check it has; nothing is sent from the lane.
- What it takes to get in, beside the title (its own line on a phone): "2 open spots · from 100K TWIG", or
"Full · 5th place holds 1.2M TWIG". The lane read carries `entry` from the same ranked list the token page uses.
- Each card credits the wallet that sent the most TWIG for it in the last 24 hours ("by @name ✓" via WhoName, the
short address otherwise; ties go to the newer boost, then the address). The credit replaces the card's "Latest
burn" link; the burns stay on the token page's Boost panel. A failed credit read never fails the lane.
---
app/src/app/llms.txt/route.ts | 2 +-
app/src/components/launchpad/BoostPicker.tsx | 151 ++++++++++++
app/src/components/launchpad/BoostedLane.tsx | 233 +++++++++++-------
app/src/components/launchpad/boost-ui.test.ts | 57 ++++-
app/src/components/ui.ts | 1 +
app/src/lib/launchpad/boost.test.ts | 30 +++
app/src/lib/launchpad/boost.ts | 51 ++++
app/src/lib/launchpad/boostServer.ts | 43 +++-
docs/boost.md | 8 +
9 files changed, 466 insertions(+), 110 deletions(-)
create mode 100644 app/src/components/launchpad/BoostPicker.tsx
diff --git a/app/src/app/llms.txt/route.ts b/app/src/app/llms.txt/route.ts
index 1af2b0bc..8d8b4be7 100644
--- a/app/src/app/llms.txt/route.ts
+++ b/app/src/app/llms.txt/route.ts
@@ -15,7 +15,7 @@ To boost a launch: TWIG (${BOOST_TOKEN}).transfer(0x…dEaD, amount) with amount
(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, clicks, launch)
+- GET ${SITE_URL}/api/boost?chain=base (the lane: twig, boosts, last_at, expires_at, clicks, top_booster, launch; entry)
- GET ${SITE_URL}/api/boost?chain=base&token=0x… (one token: twig in the window, place, hidden)
`;
}
diff --git a/app/src/components/launchpad/BoostPicker.tsx b/app/src/components/launchpad/BoostPicker.tsx
new file mode 100644
index 00000000..5dc33ffe
--- /dev/null
+++ b/app/src/components/launchpad/BoostPicker.tsx
@@ -0,0 +1,151 @@
+"use client";
+
+import { useEffect, useId, useState } from "react";
+import { useRouter } from "next/navigation";
+import { ChevronRight, Search } from "lucide-react";
+import Sheet from "@/components/Sheet";
+import TokenAvatar from "./TokenAvatar";
+import { useHydratedAccount } from "@/lib/useHydratedAccount";
+import { isSearchableQuery, paletteTokenFrom, type PaletteToken } from "@/lib/command-palette";
+import { marketUsd } from "@/lib/launchpad/market-format";
+import { setPendingToken } from "@/lib/launchpad/token-transition";
+import { BOOST_CHAIN, BOOST_COPY } from "@/lib/launchpad/boost";
+
+type LaunchesResponse = { launches?: Parameters[0][] };
+
+/** How many of the wallet's own launches lead the list, and how many trending tokens fill it before anything is typed. */
+const MINE_MAX = 5;
+const TRENDING_MAX = 6;
+
+/**
+ * "Boost a token" from the Boosted lane: pick a Base token, land on its page with the boost dialog open
+ * (`?boost=1#boost`, the same link /me's Boost uses). Nothing is sent from here: the token's page shows the amount, the
+ * lane and the burn before the wallet is asked, with every check the dialog has. The connected wallet's own Base
+ * launches come first (their creators are the likeliest boosters), then a search of Base tokens, or what is trending
+ * on Base before anything is typed.
+ */
+export default function BoostPicker({ onClose }: { onClose: () => void }) {
+ const router = useRouter();
+ const { address } = useHydratedAccount();
+ const inputId = useId();
+ const [query, setQuery] = useState("");
+ const [mine, setMine] = useState<{ wallet: string; tokens: PaletteToken[] } | null>(null);
+ const [trending, setTrending] = useState(null);
+ const [results, setResults] = useState<{ q: string; tokens: PaletteToken[]; failed: boolean } | null>(null);
+
+ // the wallet's own Base launches, newest first (what /me lists)
+ const wallet = address?.toLowerCase() ?? null;
+ useEffect(() => {
+ if (!wallet) return;
+ const ac = new AbortController();
+ fetch(`/api/me?wallet=${wallet}`, { signal: ac.signal, cache: "no-store" })
+ .then((r) => (r.ok ? (r.json() as Promise) : null))
+ .then((j) => setMine({ wallet, tokens: (j?.launches ?? []).filter((l) => l.chain === BOOST_CHAIN).slice(0, MINE_MAX).map(paletteTokenFrom) }))
+ .catch(() => {
+ if (!ac.signal.aborted) setMine({ wallet, tokens: [] });
+ });
+ return () => ac.abort();
+ }, [wallet]);
+
+ useEffect(() => {
+ const ac = new AbortController();
+ fetch(`/api/launch/list?chain=${BOOST_CHAIN}&sort=live&limit=${TRENDING_MAX}`, { signal: ac.signal, cache: "no-store" })
+ .then((r) => (r.ok ? (r.json() as Promise) : null))
+ .then((j) => setTrending((j?.launches ?? []).filter((l) => l.chain === BOOST_CHAIN).map(paletteTokenFrom)))
+ .catch(() => {
+ if (!ac.signal.aborted) setTrending([]);
+ });
+ return () => ac.abort();
+ }, []);
+
+ // Base tokens only, debounced; a late answer for an older query is dropped
+ const q = query.trim();
+ useEffect(() => {
+ if (!isSearchableQuery(q)) return;
+ const ac = new AbortController();
+ const timer = setTimeout(() => {
+ fetch(`/api/launch/search?q=${encodeURIComponent(q)}&chain=${BOOST_CHAIN}&order=holders`, { signal: ac.signal, cache: "no-store" })
+ .then((r) => (r.ok ? (r.json() as Promise) : Promise.reject(new Error(String(r.status)))))
+ .then((j) => setResults({ q, tokens: (j.launches ?? []).filter((l) => l.chain === BOOST_CHAIN).slice(0, 8).map(paletteTokenFrom), failed: false }))
+ .catch(() => {
+ if (!ac.signal.aborted) setResults({ q, tokens: [], failed: true });
+ });
+ }, 160);
+ return () => {
+ clearTimeout(timer);
+ ac.abort();
+ };
+ }, [q]);
+
+ // the boost happens on the token's page: this only navigates there, with the dialog asked to open
+ const pick = (t: PaletteToken) => {
+ setPendingToken({ chain: t.chain, token: t.token, name: t.name, symbol: t.symbol, image: t.image });
+ onClose();
+ router.push(`/t/${BOOST_CHAIN}/${t.token}?boost=1#boost`);
+ };
+
+ const searching = isSearchableQuery(q);
+ const current = results && results.q === q ? results : null;
+ const own = mine && mine.wallet === wallet ? mine.tokens : [];
+ const status = searching ? (current ? (current.failed ? BOOST_COPY.pickerFailed : current.tokens.length === 0 ? BOOST_COPY.pickerNone : "") : "Searching…") : "";
+
+ return (
+
+
+
{BOOST_COPY.pickerNote}
+
+
+
+ {/* 16px on a phone: iOS zooms the page into any smaller input it focuses */}
+ setQuery(e.target.value)}
+ placeholder="Name, symbol or address"
+ className="min-h-11 min-w-0 flex-1 bg-transparent text-base text-ink outline-none placeholder:text-faint focus-visible:outline-none! sm:text-sm"
+ />
+
+
+ );
+}
diff --git a/app/src/components/launchpad/BoostedLane.tsx b/app/src/components/launchpad/BoostedLane.tsx
index 8654ab60..629357e9 100644
--- a/app/src/components/launchpad/BoostedLane.tsx
+++ b/app/src/components/launchpad/BoostedLane.tsx
@@ -1,19 +1,25 @@
"use client";
import Link from "next/link";
+import dynamic from "next/dynamic";
import { useEffect, useRef, useState, type CSSProperties } from "react";
import { motion, useReducedMotion } from "motion/react";
-import { ArrowUpRight, ChevronDown, MousePointerClick } from "lucide-react";
+import { ArrowUpRight, ChevronDown, Flame, MousePointerClick, Plus } from "lucide-react";
import TokenAvatar from "./TokenAvatar";
import { TwigMark } from "./TwigBadge";
import { useLive } from "./LiveProvider";
import LiveNumber from "@/components/LiveNumber";
+import { btn } from "@/components/ui";
+import { WhoName } from "@/components/profile/Who";
import type { BoostLane } from "@/lib/launchpad/boostServer";
-import { BOOST_COPY, BOOST_LANE_SIZE, boostFuse, boostIgnitions, boostTimeLeft, boostWholeOf, type BoostSource } from "@/lib/launchpad/boost";
+import { BOOST_CHAIN, BOOST_COPY, BOOST_FLOOR_TWIG, BOOST_LANE_SIZE, boostEntryLine, boostFuse, boostIgnitions, boostTimeLeft, boostWholeOf, compactWhole, type BoostSource } from "@/lib/launchpad/boost";
import { capDisplay } from "@/lib/launchpad/market-cap";
-import { explorerTx } from "@/lib/chainPublic";
+import { explorerAddress, shortAddr } from "@/lib/chainPublic";
import { setPendingToken } from "@/lib/launchpad/token-transition";
+// the picker (its searches) loads on first use, not with the page
+const BoostPicker = dynamic(() => import("./BoostPicker"), { ssr: false });
+
/** 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;
/** A card's clicks: "312", "1.2K". */
@@ -29,6 +35,8 @@ const EMBERS: readonly { dx: number; dy: number; delay: number; size: number; in
];
/** An ignition is drawn for this long; older entries are dropped when the next one is recorded. */
const IGNITE_KEEP_MS = 10_000;
+/** "from 100K TWIG": what an open spot asks. */
+const FROM_FLOOR = `from ${compactWhole(Number(BOOST_FLOOR_TWIG))} TWIG`;
/**
* The lane's beacon to /api/boost/seen: the lane came on screen, or a card was clicked. sendBeacon outlives the
@@ -59,8 +67,13 @@ function sendSeen(body: { kind: "view" } | { kind: "click"; token: string }, sou
* a click only while the token is still in the lane, once per visitor and 30 minutes. Each card shows its clicks of
* the last 24 hours (the window its TWIG is counted in) once there is one, rolling up when a poll brings more.
*
- * Under 640px a card is one compact row (the name left; TWIG, clicks and time left on the right) so all five places
- * fit on a phone screen; "Latest burn" is on the token page there. From 640px it is a card, as before.
+ * Under 640px a card is one compact row (the name left; TWIG, clicks and time left on the right, the credit under
+ * it) so all five places fit on a phone screen. From 640px it is a card. Each card credits the wallet that sent the
+ * most TWIG for it in the window ("by @name"); the burn itself is one step away, on the token's page.
+ *
+ * The lane invites a boost: "Boost a token" in its header, every open place a button (one row for all of them below
+ * 1024px), and a line saying what it takes to get in. Each opens the picker, which only navigates to the token's page
+ * with the boost dialog open: nothing is sent from the lane.
*/
export default function BoostedLane({ initial, serverNow, source }: { initial: BoostLane; serverNow: number; source: BoostSource }) {
const { subscribe, wantBoost } = useLive();
@@ -70,6 +83,7 @@ export default function BoostedLane({ initial, serverNow, source }: { initial: B
// what each card held at the last snapshot: a token with more TWIG than this, freshly boosted, lights up
const prev = useRef
: null}
+
+ {items.map((b, i) => {
+ const l = b.launch;
+ const cap = capDisplay(l.fdv_quote, l.quote_usd, { key: l.quote_key, symbol: l.quote_symbol, decimals: l.quote_decimals }).compact;
+ const left = boostTimeLeft(b.last_at, now);
+ const fuse = boostFuse(b.last_at, now);
+ const lit = ignited[l.token];
+ // absent from a machine on the older build while a deploy rolls: no count shown, never "undefined"
+ const clicks = b.clicks ?? 0;
+ // two columns from 640px: an odd last card takes the whole row instead of leaving a hole
+ const lastOdd = i === items.length - 1 && items.length % 2 === 1;
+ // the whole card opens the token (the link's ::after covers it); only the credit's name sits above as its own link
+ return (
+
+ {/* one warm ring per ignition: a new key replays it */}
+ {lit ? : null}
+ {/* hover: a still warm edge and an arrow, only under a pointer that hovers; reduced motion keeps the colour */}
+
+
+ {
+ setPendingToken({ chain: l.chain, token: l.token, name: l.name, symbol: l.symbol, image: l.image_url });
+ sendSeen({ kind: "click", token: l.token }, source);
+ }}
+ // a middle click opens a tab without a click event
+ onAuxClick={(e) => {
+ if (e.button === 1) sendSeen({ kind: "click", token: l.token }, source);
+ }}
+ className="flex min-w-0 items-center gap-2.5 outline-none [grid-area:link] after:absolute after:inset-0 after:z-[1] focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-brand sm:pr-5"
>
- {/* one warm ring per ignition: a new key replays it */}
- {lit ? : null}
- {/* hover: a still warm edge and an arrow, only under a pointer that hovers; reduced motion keeps the colour */}
-
-
- {
- setPendingToken({ chain: l.chain, token: l.token, name: l.name, symbol: l.symbol, image: l.image_url });
- sendSeen({ kind: "click", token: l.token }, source);
- }}
- // a middle click opens a tab without a click event
- onAuxClick={(e) => {
- if (e.button === 1) sendSeen({ kind: "click", token: l.token }, source);
- }}
- className="flex min-w-0 items-center gap-2.5 outline-none [grid-area:link] after:absolute after:inset-0 after:z-[1] focus-visible:after:ring-2 focus-visible:after:ring-inset focus-visible:after:ring-brand sm:pr-5"
- >
-
-
- {l.name}
- {l.symbol} · , market cap {cap}
-
-
-
-
- {lit ? (
-
- {EMBERS.map((e, k) => (
-
- ))}
-
- ) : null}
+
+
+ {l.name}
+ {l.symbol} · , market cap {cap}
- {/* clicks from this lane in the last 24 hours; nothing until the first, so a fresh boost never reads "0" */}
- {clicks > 0 ? (
-
-
-
- {clicks === 1 ? " click" : " clicks"} in the last 24 hours
+
+
+
+ {lit ? (
+
+ {EMBERS.map((e, k) => (
+
+ ))}
) : null}
- {left}
-
- Latest burn
-
- {/* the fuse: what is left of the 24 hours, burning down from the right; steps with the card's clock */}
-
-
- {fuse > 0 ? : null}
-
+
+ {/* clicks from this lane in the last 24 hours; nothing until the first, so a fresh boost never reads "0" */}
+ {clicks > 0 ? (
+
+
+
+ {clicks === 1 ? " click" : " clicks"} in the last 24 hours
+
+ ) : null}
+ {left}
+ {/* credit: the wallet that sent the most TWIG for it in the window; only the name is a link (above the card's) */}
+ {b.top_booster ? (
+
+ by
+ {shortAddr(b.top_booster)}}
+ />
+
+ ) : null}
+ {/* the fuse: what is left of the 24 hours, burning down from the right; steps with the card's clock */}
+
+
+ {fuse > 0 ? : null}
-
- );
- })}
- {/* five columns from 1024px: the free places read as open, never as a grey block */}
- {Array.from({ length: Math.max(0, BOOST_LANE_SIZE - items.length) }, (_, i) => (
-
Open spot
- ))}
-
- )}
+
+
+ );
+ })}
+ {/* five columns from 1024px: each free place is a button into the picker */}
+ {Array.from({ length: open }, (_, i) => (
+
+
+
+ ))}
+ {/* under 1024px: one row for all the free places, so the cards stay first */}
+ {open > 0 ? (
+
- ) : null}
-
+
+ );
+ })}
+ {/* five columns from 1024px: each free place is a button into the picker */}
+ {Array.from({ length: open }, (_, i) => (
+
+ ) : null}
+
+
{picking ? setPicking(false)} /> : null}
);
diff --git a/app/src/components/launchpad/boost-ui.test.ts b/app/src/components/launchpad/boost-ui.test.ts
index e3b95689..fed23b69 100644
--- a/app/src/components/launchpad/boost-ui.test.ts
+++ b/app/src/components/launchpad/boost-ui.test.ts
@@ -324,12 +324,14 @@ test("the lane never counts a view of its empty line, and the hover arrow never
});
const picker = read("./BoostPicker.tsx");
+const list = read("../../app/api/launch/list/route.ts");
test("the lane invites a boost: a header button, every open place a button, and what it takes to get in", () => {
- assert.match(lane, /const open = Math\.max\(0, BOOST_LANE_SIZE - items\.length\);\n\s*const entryLine = boostEntryLine\(open, lane\.entry\);/);
+ assert.match(lane, /const open = Math\.max\(0, BOOST_LANE_SIZE - items\.length\);\n\s*\/\/ read off the cards shown, so it can never contradict them\n\s*const entryLine = boostEntryLine\(items\.map\(\(b\) => b\.twig\)\);/);
+ assert.doesNotMatch(server.slice(server.indexOf("export async function getBoostLane("), server.indexOf("export async function boostLaneOrNull(")), /laneEntry/, "the lane's line is never the server's ranked cutoff");
assert.match(lane, /\n\s*\n\s*\{BOOST_COPY\.cta\}/);
// the entry line never squeezes beside the button on a phone: a full line of its own there
- assert.match(lane, /\{entryLine \?
\{entryLine\}<\/p>/);
// one button per open place from 1024px, one row for all of them below; none once the lane is full
assert.match(lane, /\{Array\.from\(\{ length: open \}, \(_, i\) => \(\n\s*
\n\s* 0 \? \(\n\s*
/);
@@ -337,17 +339,31 @@ test("the lane invites a boost: a header button, every open place a button, and
// the picker loads on first use; the lane itself never sends anything
assert.match(lane, /const BoostPicker = dynamic\(\(\) => import\("\.\/BoostPicker"\), \{ ssr: false \}\);/);
assert.match(lane, /\{picking \? setPicking\(false\)\} \/> : null\}/);
- // the lane carries its entry from the same ranked list the token page reads
- assert.match(server, /return \{ enabled: true, window_h: BOOST_WINDOW_H, floor_twig: BOOST_FLOOR_TWIG\.toString\(\), items, entry: laneEntry\(ranked\) \};/);
+ // the list counts the lane's places (cards, then open ones), not only boosted tokens
+ assert.match(lane, / null\);\n\s*return known \? Object\.fromEntries\(wallets\.map\(\(w\) => \[w, known\[w\] \?\? null\]\)\) : \{\};/);
+ assert.match(lane, //);
+ // a public read of the lane comes from the same few-second cache: polling it adds no database pass
+ assert.match(boostRoute, /const lane = await boostLaneOrNull\(await ethUsd\(\)\);/);
+ assert.doesNotMatch(boostRoute, /getBoostLane/);
});
test("the picker only navigates: the boost happens on the token's page, with every check the dialog has", () => {
assert.match(picker, /router\.push\(`\/t\/\$\{BOOST_CHAIN\}\/\$\{t\.token\}\?boost=1#boost`\);/);
assert.doesNotMatch(picker, /wagmi|viem|writeContract|sendTransaction|simulateContract|signMessage|BoostDialog/);
- // Base only, in every list it reads
+ // Base only, in every list it reads; the wallet's own launches by the launcher filter, only as many as are shown
assert.match(picker, /\/api\/launch\/search\?q=\$\{encodeURIComponent\(q\)\}&chain=\$\{BOOST_CHAIN\}&order=holders/);
assert.match(picker, /\/api\/launch\/list\?chain=\$\{BOOST_CHAIN\}&sort=live/);
+ assert.match(picker, /\/api\/launch\/list\?chain=\$\{BOOST_CHAIN\}&launcher=\$\{wallet\}&sort=new&limit=\$\{MINE_MAX\}/);
+ assert.doesNotMatch(picker, /\/api\/me/);
assert.equal((picker.match(/\.filter\(\(l\) => l\.chain === BOOST_CHAIN\)/g) ?? []).length, 3);
+ // a token an admin keeps out of the lane is said so here, never a trip to a page with no Boost button
+ assert.match(picker, /const kept = await fetch\(`\/api\/boost\?chain=\$\{BOOST_CHAIN\}&token=\$\{t\.token\}`/);
+ assert.match(picker, /if \(kept\) \{\n\s*setHidden\(\(h\) => new Set\(h\)\.add\(t\.token\)\);\n\s*return;\n\s*\}/);
+ assert.ok(picker.indexOf("if (kept)") < picker.indexOf("router.push("));
+ assert.match(list, /const launcher = l && isAddress\(l\) \? l\.toLowerCase\(\) : undefined;/);
// the connected wallet's own launches first; an answer for another wallet is never shown
assert.match(picker, /const own = mine && mine\.wallet === wallet \? mine\.tokens : \[\];/);
assert.match(picker, /BOOST_COPY\.pickerNote/);
diff --git a/app/src/lib/launchpad/boost.test.ts b/app/src/lib/launchpad/boost.test.ts
index 4f3d7c41..ca335a16 100644
--- a/app/src/lib/launchpad/boost.test.ts
+++ b/app/src/lib/launchpad/boost.test.ts
@@ -33,6 +33,7 @@ import {
rankLane,
BOOST_CLICK_SLOT_MS,
BOOST_CLICKS_KEEP_DAYS,
+ BOOST_FROM_FLOOR,
boostEntryLine,
topBoosters,
BOOST_SOURCES,
@@ -316,15 +317,16 @@ test("only the site's own pages may send the beacon: another site's visitors can
assert.equal(boostBeaconSameOrigin(null, null, HOST), true);
});
-test("the lane says what it takes to get in: open spots and the floor, or what the last place holds", () => {
- assert.equal(boostEntryLine(2, { full: false, cutoff: null }), "2 open spots · from 100K TWIG");
- assert.equal(boostEntryLine(1, null), "1 open spot · from 100K TWIG");
- assert.equal(boostEntryLine(5, undefined), "5 open spots · from 100K TWIG", "an empty lane");
- assert.equal(boostEntryLine(0, { full: true, cutoff: (1_234_567n * E18 + ID).toString() }), "Full · 5th place holds 1.23M TWIG");
- assert.equal(boostEntryLine(0, undefined), null, "an older server mid-deploy: no line rather than a wrong one");
- assert.equal(boostEntryLine(0, { full: true, cutoff: null }), null);
+test("the lane says what it takes to get in, read off the cards it shows", () => {
+ const twig = (whole: bigint) => (whole * E18 + ID).toString();
+ assert.equal(BOOST_FROM_FLOOR, "from 100K TWIG");
+ assert.equal(boostEntryLine([twig(3_000_000n), twig(500_000n), twig(300_000n)]), "2 open spots · from 100K TWIG");
+ assert.equal(boostEntryLine([twig(1n), twig(1n), twig(1n), twig(1n)]), "1 open spot · from 100K TWIG");
+ assert.equal(boostEntryLine([]), "5 open spots · from 100K TWIG", "an empty lane");
+ // full: the 5th card's own total, so the line and the cards always agree
+ assert.equal(boostEntryLine([twig(9_000_000n), twig(3_000_000n), twig(2_000_000n), twig(1_500_000n), twig(1_234_567n)]), "Full · 5th place holds 1.23M TWIG");
const banned = /\b(moon|rally|buyback|guarantee[ds]?|pump|gem|100x|profit|invest(ment)?|returns?|yield|airdrop|reward)\b/i;
- for (const line of [boostEntryLine(3, null), boostEntryLine(0, { full: true, cutoff: "1" })]) assert.ok(line && !banned.test(line), String(line));
+ for (const line of [boostEntryLine([twig(1n)]), boostEntryLine(Array(5).fill(twig(100_000n)))]) assert.ok(!banned.test(line), line);
});
test("a card credits the wallet that sent the most TWIG in the window; a tie goes to the newer boost, then the address", () => {
diff --git a/app/src/lib/launchpad/boost.ts b/app/src/lib/launchpad/boost.ts
index 9cfd4b31..587ea9e5 100644
--- a/app/src/lib/launchpad/boost.ts
+++ b/app/src/lib/launchpad/boost.ts
@@ -203,16 +203,18 @@ function ordinal(n: number): string {
return `${n}${["th", "st", "nd", "rd"][n % 10] ?? "th"}`;
}
+/** "from 100K TWIG": what an open place asks, in the lane's entry line and on its open-place buttons. */
+export const BOOST_FROM_FLOOR = `from ${compactWhole(Number(BOOST_FLOOR_TWIG))} ${TWIG_SYMBOL}`;
+
/**
- * What it takes to get into the lane, under its title: "2 open spots · from 100K TWIG" while there is room, "Full ·
- * 5th place holds 1.2M TWIG" once there is not (a boost has to reach that). null when the full lane's last place is
- * unknown (an older server mid-deploy): no line rather than a wrong one.
+ * What it takes to get into the lane, read off the cards it shows (raw TWIG totals in lane order), so the line can
+ * never contradict them: "2 open spots · from 100K TWIG" while there is room, "Full · 5th place holds 1.2M TWIG" (what
+ * the last card shows, and what a boost has to reach) once there is not.
*/
-export function boostEntryLine(open: number, entry: LaneEntry | null | undefined): string | null {
- const floor = `from ${compactWhole(Number(BOOST_FLOOR_TWIG))} ${TWIG_SYMBOL}`;
- if (open > 0) return `${open} open ${open === 1 ? "spot" : "spots"} · ${floor}`;
- if (entry?.full && entry.cutoff !== null) return `Full · ${ordinal(BOOST_LANE_SIZE)} place holds ${fmtBoostTwig(entry.cutoff)}`;
- return null;
+export function boostEntryLine(totals: readonly string[]): string {
+ const open = BOOST_LANE_SIZE - totals.length;
+ if (open > 0) return `${open} open ${open === 1 ? "spot" : "spots"} · ${BOOST_FROM_FLOOR}`;
+ return `Full · ${ordinal(BOOST_LANE_SIZE)} place holds ${fmtBoostTwig(totals[BOOST_LANE_SIZE - 1])}`;
}
/** One wallet's boosts on one token inside the window (the database groups; topBoosters picks). */
@@ -356,6 +358,7 @@ export const BOOST_COPY = {
pickerSearch: "Search Base tokens",
pickerNone: "No Base token matches.",
pickerFailed: "Search is unavailable right now. Any token's page still has its Boost button.",
+ pickerHidden: "Not shown in the lane",
what: "The Boosted lane shows the 5 tokens with the most TWIG sent for them in the last 24 hours. A boost counts toward that total for 24 hours. It does not change the Live sort or the Trending board, and it is not an endorsement.",
tag: "The last digits of the amount name this token, so the transfer itself records what it boosted.",
final: "The TWIG goes to 0x…dEaD. It cannot be undone or refunded, and nothing is paid to openlaunch or Twigpine.",
diff --git a/app/src/lib/launchpad/boostServer.ts b/app/src/lib/launchpad/boostServer.ts
index 61e456a7..249aae32 100644
--- a/app/src/lib/launchpad/boostServer.ts
+++ b/app/src/lib/launchpad/boostServer.ts
@@ -7,6 +7,7 @@ import { forgetMemo, memo } from "./memo";
import { boostEnabled } from "./boostSwitch";
import { tsFresh } from "./posts";
import { adminWallets, consumeNonce, isNonce, verify } from "./postsServer";
+import { namesFor, type NameEntry } from "@/lib/profiles/server";
import {
BOOST_CHAIN,
BOOST_CHAIN_ID,
@@ -41,11 +42,11 @@ export { boostEnabled };
/**
* `clicks`: the card's clicks from the lane in the last 24 hours (bb_boost_clicks), the window its TWIG is counted in.
* `top_booster`: the wallet that sent the most TWIG for it in that window (the card's credit).
- * `entry` (on the lane): is it full, and what its last place holds: the lane's "what it takes" line.
- * All three optional for readers: a machine still on an older build answers polls without them while a deploy rolls.
+ * `names` (on the lane): the credited wallets' public names, null for a wallet with none, so the first paint shows them.
+ * All optional for readers: a machine still on an older build answers polls without them while a deploy rolls.
*/
export type BoostLaneItem = LaneTotals & { expires_at: string; launch: LaunchRow; clicks?: number; top_booster?: string };
-export type BoostLane = { enabled: false } | { enabled: true; window_h: number; floor_twig: string; items: BoostLaneItem[]; entry?: LaneEntry };
+export type BoostLane = { enabled: false } | { enabled: true; window_h: number; floor_twig: string; items: BoostLaneItem[]; names?: Record };
export type TokenBoost =
| { enabled: false }
| { enabled: true; hidden: boolean; twig: string; boosts: number; boosters: number; last_at: string | null; last_tx: string | null; expires_at: string | null; place: number | null; entry: LaneEntry };
@@ -142,12 +143,21 @@ async function laneBoosters(tokens: readonly string[]): Promise