From b19688b0eecc0a93e99193efb5f847e14ff6ebaa Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Fri, 16 Oct 2026 15:57:37 +0800 Subject: [PATCH 1/3] feat(boost): what the lane brought on the token page, Share on X after a boost MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Token page: while boosted, the Boost panel shows "N clicks from the lane in the last 24 hours" (getTokenBoost counts the cards' own window), from 10 up so a fresh boost never reads like a small number. A failed count is no line. - Done screen: "Share on X" opens the booster's own post, "Boosted $SYM with 1M TWIG on openlaunch.lol. The TWIG went to 0x…dEaD.", the token's page as its link, via @openlaunch_lol (boostShareUrl; the token page's Share intent). - /api/launch/list: a first page with no wallet filter shares the live poll's 2 s memo under one key (listPageKey), so the picker's trending, the palette and the launches list's first page cost one query; paged and per-wallet reads are never shared. - /admin: a token hidden from the panel stays out of its list for 15 s, past both machines' lane caches. - llms.txt: the lane's fields as they are (names, no entry). --- app/src/app/api/launch/list/route.ts | 8 +++-- app/src/app/api/launch/live/route.ts | 4 +-- app/src/app/llms.txt/route.ts | 2 +- app/src/components/launchpad/BoostAdmin.tsx | 16 ++++++++- app/src/components/launchpad/BoostDialog.tsx | 5 ++- app/src/components/launchpad/BoostPanel.tsx | 17 +++++++--- app/src/components/launchpad/boost-ui.test.ts | 34 +++++++++++++++++++ app/src/lib/launchpad/boost.test.ts | 20 +++++++++++ app/src/lib/launchpad/boost.ts | 19 +++++++++++ app/src/lib/launchpad/boostServer.ts | 21 +++++++++--- app/src/lib/launchpad/queries.ts | 8 +++++ docs/boost.md | 7 ++-- 12 files changed, 143 insertions(+), 18 deletions(-) diff --git a/app/src/app/api/launch/list/route.ts b/app/src/app/api/launch/list/route.ts index d72259d4..4e12b01f 100644 --- a/app/src/app/api/launch/list/route.ts +++ b/app/src/app/api/launch/list/route.ts @@ -3,7 +3,8 @@ import { isAddress } from "viem"; import { visibleChainOr } from "@/lib/launchpad/config"; import { isFilter } from "@/lib/launchpad/search"; import { clampLimit, clampOffset } from "@/lib/launchpad/paging"; -import { VOLUME_WINDOWS, listLaunchesPage, parseSort, type VolumeWindow } from "@/lib/launchpad/queries"; +import { VOLUME_WINDOWS, listLaunchesPage, listPageKey, parseSort, type VolumeWindow } from "@/lib/launchpad/queries"; +import { memo } from "@/lib/launchpad/memo"; import { ethUsd } from "@/lib/launchpad/ethPrice"; export const dynamic = "force-dynamic"; @@ -27,7 +28,10 @@ export async function GET(req: Request) { const launcher = l && isAddress(l) ? l.toLowerCase() : undefined; try { const usd = await ethUsd(); - const page = await listLaunchesPage({ sort, window, chain, filter, limit, offset, launcher, ethUsd: usd }); + const opts = { sort, window, chain, filter, limit, offset, launcher, ethUsd: usd }; + // a first page with no wallet filter shares the live poll's few-second memo (same key): the picker's trending, the + // palette and the launches list's first page cost one query between them; a paged or per-wallet read is never shared + const page = offset === 0 && !launcher ? await memo(listPageKey(opts), 2_000, () => listLaunchesPage(opts)) : await listLaunchesPage(opts); return NextResponse.json({ sort, window, chain, filter, limit, offset, has_more: page.hasMore, ethUsd: usd, launches: page.items }, { headers: { "cache-control": "no-store" } }); } catch (err) { console.error("[launch] list failed:", err instanceof Error ? err.message : err); diff --git a/app/src/app/api/launch/live/route.ts b/app/src/app/api/launch/live/route.ts index cc32155a..280bffad 100644 --- a/app/src/app/api/launch/live/route.ts +++ b/app/src/app/api/launch/live/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { visibleChainOr } from "@/lib/launchpad/config"; import { isFilter } from "@/lib/launchpad/search"; import { clampLimit } from "@/lib/launchpad/paging"; -import { VOLUME_WINDOWS, getLaunchFeed, getLaunchTotals, getTrending, isTrendingSource, listLaunchesPage, parseSort, trendingFrom, type VolumeWindow } from "@/lib/launchpad/queries"; +import { VOLUME_WINDOWS, getLaunchFeed, getLaunchTotals, getTrending, isTrendingSource, listLaunchesPage, listPageKey, parseSort, trendingFrom, type VolumeWindow } from "@/lib/launchpad/queries"; import { ethUsd } from "@/lib/launchpad/ethPrice"; import { memo } from "@/lib/launchpad/memo"; import { listFeed } from "@/lib/launchpad/postsServer"; @@ -36,7 +36,7 @@ export async function GET(req: Request) { 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), + listOpts ? memo(listPageKey(listOpts), 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 diff --git a/app/src/app/llms.txt/route.ts b/app/src/app/llms.txt/route.ts index 8d8b4be7..2269f958 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, top_booster, launch; entry) +- GET ${SITE_URL}/api/boost?chain=base (the lane: twig, boosts, last_at, expires_at, clicks, top_booster, launch; names) - GET ${SITE_URL}/api/boost?chain=base&token=0x… (one token: twig in the window, place, hidden) `; } diff --git a/app/src/components/launchpad/BoostAdmin.tsx b/app/src/components/launchpad/BoostAdmin.tsx index d8472843..bf51ad31 100644 --- a/app/src/components/launchpad/BoostAdmin.tsx +++ b/app/src/components/launchpad/BoostAdmin.tsx @@ -19,12 +19,18 @@ import { nonce } from "@/lib/nonce"; * 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. Below them, the lane's views and clicks per token. */ +/** How long a token hidden from this panel stays out of its list, past both machines' lane caches. */ +const JUST_HIDDEN_MS = 15_000; + 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 token hidden here stays out of the list for a while: the reload after a hide can reach the other machine, whose + // lane cache (two stacked few-second memos) may still hold it for up to ~10 seconds + const [justHidden, setJustHidden] = useState>(new Set()); // 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 { @@ -51,6 +57,14 @@ export default function BoostAdmin() { 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}` }); + const t = token.toLowerCase(); + setJustHidden((s) => { + const next = new Set(s); + if (action === "hide") next.add(t); + else next.delete(t); + return next; + }); + if (action === "hide") setTimeout(() => setJustHidden((s) => (s.has(t) ? new Set([...s].filter((x) => x !== t)) : s)), JUST_HIDDEN_MS); // 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(); @@ -66,7 +80,7 @@ export default function BoostAdmin() { {!address ?

Connect the admin wallet.

: null} {lane?.enabled && lane.items.length === 0 ?

Nothing is boosted right now.

: null} {lane?.enabled - ? lane.items.map((b) => ( + ? lane.items.filter((b) => !justHidden.has(b.token)).map((b) => (
{b.launch.name} diff --git a/app/src/components/launchpad/BoostDialog.tsx b/app/src/components/launchpad/BoostDialog.tsx index 0d622e96..8f3c98bc 100644 --- a/app/src/components/launchpad/BoostDialog.tsx +++ b/app/src/components/launchpad/BoostDialog.tsx @@ -16,7 +16,7 @@ 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 { BUILDER_DATA_SUFFIX, CHAINS, SITE_URL, explorerTx } from "@/lib/chainPublic"; import { BOOST_CHAIN, BOOST_COPY, @@ -33,6 +33,7 @@ import { isLaunchTag, maxBoostWhole, parseBoostWhole, + boostShareUrl, } from "@/lib/launchpad/boost"; import { toast } from "./TxToasts"; import { TwigMark } from "./TwigBadge"; @@ -150,6 +151,8 @@ export default function BoostDialog({ token, tokenId, symbol, name, twigUsd, cur

{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.

View transaction + {/* the booster's own post: what was sent and where it went, the token's page as its link */} + Share on X See the lane
diff --git a/app/src/components/launchpad/BoostPanel.tsx b/app/src/components/launchpad/BoostPanel.tsx index 207e29cf..2708e2a0 100644 --- a/app/src/components/launchpad/BoostPanel.tsx +++ b/app/src/components/launchpad/BoostPanel.tsx @@ -2,12 +2,12 @@ import dynamic from "next/dynamic"; import { useCallback, useEffect, useRef, useState } from "react"; -import { ArrowUpRight } from "lucide-react"; +import { ArrowUpRight, MousePointerClick } 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 { BOOST_CHAIN, BOOST_COPY, BOOST_PANEL_CLICKS_MIN, boostTimeLeft, fmtBoostTwig } from "@/lib/launchpad/boost"; import { explorerTx } from "@/lib/chainPublic"; import type { PendingBoost } from "./BoostDialog"; @@ -15,8 +15,8 @@ import type { PendingBoost } from "./BoostDialog"; 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 + * A Base token's boost line on its page: whether it is in the Boosted lane, with how much TWIG and for how long, what + * the lane brought it (clicks in the same 24 hours, from BOOST_PANEL_CLICKS_MIN up), 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 }) { @@ -51,6 +51,8 @@ export default function BoostPanel({ token, tokenId, symbol, name, initial, twig if (!state.enabled) return null; const left = state.last_at ? boostTimeLeft(state.last_at, now) : null; const active = left !== null && BigInt(state.twig) > 0n; + // absent from a machine on an older build mid-deploy: no line, never "undefined" + const clicks = state.clicks ?? 0; return (
@@ -68,6 +70,13 @@ export default function BoostPanel({ token, tokenId, symbol, name, initial, twig <>Not boosted. Sending TWIG to 0x…dEaD puts {symbol} in the Boosted lane for 24 hours. )}

+ {/* what the lane brought it: only while boosted, and not until it reads like a result */} + {active && clicks >= BOOST_PANEL_CLICKS_MIN ? ( +

+

+ ) : null}
{active && state.last_tx ? Latest burn : null} diff --git a/app/src/components/launchpad/boost-ui.test.ts b/app/src/components/launchpad/boost-ui.test.ts index fe5e00c4..56a7e003 100644 --- a/app/src/components/launchpad/boost-ui.test.ts +++ b/app/src/components/launchpad/boost-ui.test.ts @@ -382,3 +382,37 @@ test("a card credits the wallet that sent the most TWIG for it; a failed read sh assert.match(body, /\} catch \(err\) \{\n\s*console\.warn\("\[boost\] lane boosters read failed:"[^\n]*\n\s*return new Map\(\);/); assert.match(lane, /\{b\.top_booster \? \(/); }); + +const liveRouteSrc = read("../../app/api/launch/live/route.ts"); + +test("the token page shows what the lane brought: clicks in the same 24 hours, from 10 up, only while boosted", () => { + assert.match(panel, /const clicks = state\.clicks \?\? 0;/); + assert.match(panel, /\{active && clicks >= BOOST_PANEL_CLICKS_MIN \? \(/); + assert.match(panel, /clicks from the lane in the last 24 hours/); + const body = server.slice(server.indexOf("export async function getTokenBoost("), server.indexOf("/* ─────────────────────────────── views and clicks")); + assert.match(body, /SELECT count\(\*\)::int AS clicks FROM bb_boost_clicks\n\s*WHERE chain_id = \$\{BOOST_CHAIN_ID\} AND token = \$\{t\}\n\s*AND clicked_at > now\(\) - make_interval\(hours => \$\{BOOST_WINDOW_H\}\)`\.catch\(/); + assert.match(body, /return \[\{ clicks: 0 \}\];/, "a failed count is no line, never a panel that fails"); +}); + +test("after a confirmed boost the booster can share it on X: its own post, in a new tab", () => { + const done = dialog.slice(dialog.indexOf('{phase.k === "done" ? ('), dialog.indexOf(") : (")); + assert.match(done, /Share on X/); + assert.equal((dialog.match(/boostShareUrl\(/g) ?? []).length, 1, "only on the done screen"); +}); + +test("the agents' notes name the lane's fields as they are", () => { + assert.match(llms, /\(the lane: twig, boosts, last_at, expires_at, clicks, top_booster, launch; names\)/); + assert.doesNotMatch(llms, /launch; entry\)/); +}); + +test("a list's first page with no wallet filter shares the live poll's few-second memo; paged and per-wallet reads never do", () => { + assert.match(list, /const page = offset === 0 && !launcher \? await memo\(listPageKey\(opts\), 2_000, \(\) => listLaunchesPage\(opts\)\) : await listLaunchesPage\(opts\);/); + assert.match(liveRouteSrc, /listOpts \? memo\(listPageKey\(listOpts\), 2_000, \(\) => listLaunchesPage\(listOpts\)\)/); + assert.match(queries, /export function listPageKey\(o: Pick\): string \{\n\s*return `list:\$\{o\.chain \?\? "all"\}:\$\{o\.sort\}:\$\{o\.window\}:\$\{o\.filter \?\? "-"\}:\$\{o\.limit\}`;/); +}); + +test("a token hidden from the admin panel stays out of its list past both machines' lane caches", () => { + assert.match(admin, /const JUST_HIDDEN_MS = 15_000;/); + assert.match(admin, /lane\.items\.filter\(\(b\) => !justHidden\.has\(b\.token\)\)\.map\(/); + assert.match(admin, /if \(action === "hide"\) next\.add\(t\);\n\s*else next\.delete\(t\);/); +}); diff --git a/app/src/lib/launchpad/boost.test.ts b/app/src/lib/launchpad/boost.test.ts index ca335a16..afad9e2c 100644 --- a/app/src/lib/launchpad/boost.test.ts +++ b/app/src/lib/launchpad/boost.test.ts @@ -34,6 +34,9 @@ import { BOOST_CLICK_SLOT_MS, BOOST_CLICKS_KEEP_DAYS, BOOST_FROM_FLOOR, + BOOST_PANEL_CLICKS_MIN, + boostShareText, + boostShareUrl, boostEntryLine, topBoosters, BOOST_SOURCES, @@ -345,3 +348,20 @@ test("a card credits the wallet that sent the most TWIG in the window; a tie goe assert.equal(picked.get("0xt3"), "0x999"); assert.equal(topBoosters([]).size, 0); }); + +test("a booster's post says what was sent and where it went, links the token's page, and tags the official account", () => { + const raw = 1_000_000n * E18 + ID; + assert.equal(boostShareText("MUSEWORLD", raw), "Boosted $MUSEWORLD with 1M TWIG on openlaunch.lol. The TWIG went to 0x…dEaD."); + assert.equal(boostShareText("BSTR", (426_248n * E18).toString()), "Boosted $BSTR with 426.25K TWIG on openlaunch.lol. The TWIG went to 0x…dEaD."); + const url = new URL(boostShareUrl("A&B #1", raw, "https://openlaunch.lol/t/base/0xabc")); + assert.equal(url.origin + url.pathname, "https://x.com/intent/post", "the same intent the token page's Share uses"); + assert.equal(url.searchParams.get("text"), "Boosted $A&B #1 with 1M TWIG on openlaunch.lol. The TWIG went to 0x…dEaD.", "a symbol never breaks out of its parameter"); + assert.equal(url.searchParams.get("url"), "https://openlaunch.lol/t/base/0xabc"); + assert.equal(url.searchParams.get("via"), "openlaunch_lol"); + const banned = /\b(moon|rally|buyback|guarantee[ds]?|pump|gem|100x|profit|invest(ment)?|returns?|yield|airdrop|reward)\b/i; + assert.doesNotMatch(boostShareText("X", raw), banned); +}); + +test("the token page shows its clicks from the lane only once they read like a result", () => { + assert.equal(BOOST_PANEL_CLICKS_MIN, 10); +}); diff --git a/app/src/lib/launchpad/boost.ts b/app/src/lib/launchpad/boost.ts index 587ea9e5..189621c2 100644 --- a/app/src/lib/launchpad/boost.ts +++ b/app/src/lib/launchpad/boost.ts @@ -13,6 +13,7 @@ */ import { TWIG_ADDRESS, TWIG_DECIMALS, TWIG_SYMBOL } from "./twig.ts"; import { CHAIN_IDS } from "../chainKeys.ts"; +import { BRAND_DOMAIN, BRAND_X } from "../brand.ts"; /** Boosts exist on Base only: TWIG has not been bridged anywhere. */ export const BOOST_CHAIN = "base" as const; @@ -239,6 +240,24 @@ export function topBoosters(rows: readonly BoosterTotal[]): Map return new Map([...best].map(([token, r]) => [token, r.booster])); } +/* ─────────────────────────────── results and sharing ─────────────────────────────── */ + +/** The token page shows its clicks from the lane once there are this many: a fresh boost never reads "3 clicks". */ +export const BOOST_PANEL_CLICKS_MIN = 10; + +/** + * The post a booster can share once a boost is confirmed: what was sent and where it went, nothing about price. True + * whether or not the boost brought the token into the lane yet. + */ +export function boostShareText(symbol: string, raw: bigint | string): string { + return `Boosted $${symbol} with ${fmtBoostTwig(raw)} on ${BRAND_DOMAIN}. The TWIG went to 0x…dEaD.`; +} + +/** The X intent for that post: the token's page as its link, via the official account (the token page's Share uses the same intent). */ +export function boostShareUrl(symbol: string, raw: bigint | string, pageUrl: string): string { + return `https://x.com/intent/post?text=${encodeURIComponent(boostShareText(symbol, raw))}&url=${encodeURIComponent(pageUrl)}&via=${BRAND_X}`; +} + /* ─────────────────────────────── views and clicks ─────────────────────────────── */ /** diff --git a/app/src/lib/launchpad/boostServer.ts b/app/src/lib/launchpad/boostServer.ts index 249aae32..ae650619 100644 --- a/app/src/lib/launchpad/boostServer.ts +++ b/app/src/lib/launchpad/boostServer.ts @@ -49,7 +49,7 @@ export type BoostLaneItem = LaneTotals & { expires_at: string; launch: LaunchRow 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 }; + | { 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; clicks?: number }; const RAW_FLOOR = (BOOST_FLOOR_TWIG * 10n ** 18n).toString(); const LANE_TTL_MS = 5_000; @@ -188,14 +188,18 @@ export async function boostLaneOrNull(ethUsd: number | null): Promise boostTimeLeft(b.last_at, now) !== null) }; } -/** One token's boost state for its page: TWIG inside the window, its place in the lane (1-based) and whether an admin hid it. */ +/** + * One token's boost state for its page: TWIG inside the window, its place in the lane (1-based), whether an admin hid + * it, and its clicks from the lane in the same 24 hours (the panel shows them from BOOST_PANEL_CLICKS_MIN up). `clicks` + * is optional for readers: a machine on an older build answers without it while a deploy rolls. + */ export async function getTokenBoost(token: string): Promise { if (!boostEnabled()) return { enabled: false }; const db = maybeDb(); const t = token.toLowerCase(); - const none = { enabled: true as const, hidden: false, twig: "0", boosts: 0, boosters: 0, last_at: null, last_tx: null, expires_at: null, place: null, entry: { full: false, cutoff: null } }; + const none = { enabled: true as const, hidden: false, twig: "0", boosts: 0, boosters: 0, last_at: null, last_tx: null, expires_at: null, place: null, entry: { full: false, cutoff: null }, clicks: 0 }; if (!db) return none; - const [[row], [setting], ranked] = await Promise.all([ + const [[row], [setting], ranked, [seen]] = await Promise.all([ db<{ twig: string; boosts: number; boosters: number; last_at: string | null; last_tx: string | null }[]>` SELECT COALESCE(sum(b.amount), 0)::text AS twig, count(*)::int AS boosts, count(DISTINCT b.booster)::int AS boosters, max(b.block_time) AS last_at, @@ -207,6 +211,14 @@ export async function getTokenBoost(token: string): Promise { AND b.amount >= ${RAW_FLOOR}::numeric`, db<{ boost_hidden: boolean }[]>`SELECT boost_hidden FROM bb_token_settings WHERE chain_id = ${BOOST_CHAIN_ID} AND token = ${t}`, rankedLane(), + // the lane's own window; a failed count shows no clicks line, never a panel that fails + db<{ clicks: number }[]>` + SELECT count(*)::int AS clicks FROM bb_boost_clicks + WHERE chain_id = ${BOOST_CHAIN_ID} AND token = ${t} + AND clicked_at > now() - make_interval(hours => ${BOOST_WINDOW_H})`.catch((err) => { + console.warn("[boost] token clicks read failed:", err instanceof Error ? err.message : err); + return [{ clicks: 0 }]; + }), ]); const i = ranked.findIndex((r) => r.token === t); return { @@ -220,6 +232,7 @@ export async function getTokenBoost(token: string): Promise { expires_at: row?.last_at ? new Date(boostExpiresAt(row.last_at)).toISOString() : null, place: i >= 0 && i < BOOST_LANE_SIZE ? i + 1 : null, entry: laneEntry(ranked), + clicks: seen?.clicks ?? 0, }; } diff --git a/app/src/lib/launchpad/queries.ts b/app/src/lib/launchpad/queries.ts index 2b3f36ab..244d5836 100644 --- a/app/src/lib/launchpad/queries.ts +++ b/app/src/lib/launchpad/queries.ts @@ -308,6 +308,14 @@ export type ListPage = { items: LaunchRow[]; hasMore: boolean }; * Outside tier 0 a launcher keeps only its newest row, and that row carries how many of its siblings were folded * (`launcher_collapsed`). The tier travels with the row (`live_tier`) so the client never recomputes it. */ +/** + * The few-second memo key for a list's first page, shared by the live poll (api/launch/live) and api/launch/list, so + * the same view asked through either is one query. Callers memoize only a first page with no wallet filter. + */ +export function listPageKey(o: Pick): string { + return `list:${o.chain ?? "all"}:${o.sort}:${o.window}:${o.filter ?? "-"}:${o.limit}`; +} + export async function listLaunchesPage(opts: ListOpts = {}): Promise { const db = maybeDb(); if (!db) return { items: [], hasMore: false }; diff --git a/docs/boost.md b/docs/boost.md index 7ddb1d65..4c096be1 100644 --- a/docs/boost.md +++ b/docs/boost.md @@ -44,7 +44,8 @@ The lane asks for a boost where people see it, without ever sending one itself: - **"Boost a token"** in the lane's header, on every screen size. **Every open place is a button** ("+ Open spot · from 100K TWIG", one per place from 1024px; one "+ N open spots · Boost a token" row below that, so the cards stay first). Each opens the **picker** (`BoostPicker.tsx`, loaded on first use): the connected wallet's own Base launches first (their creators are the likeliest boosters), a search of Base tokens, or what is trending on Base before anything is typed. Picking a token goes to `/t/base/?boost=1#boost`, the same link /me's Boost uses, where the dialog opens with every check it has. Nothing is sent from the lane or the picker. - **What it takes to get in**, beside the title (under it on a phone): "2 open spots · from 100K TWIG" while there is room, "Full · 5th place holds 1.2M TWIG" once there is not. It is read off the cards the lane shows (`boostEntryLine`), so it never contradicts them. The token page's dialog keeps its own rule from the full ranking; the two differ only when a ranked token's market row cannot be read and its card is left out. - **Credit:** each card names the wallet that sent the most TWIG for it in the last 24 hours ("by @name ✓" with a profile, the short address otherwise; a tie goes to the newer boost, then the address). Only the name is a link, above the card's own. The lane read carries `top_booster` and the credited wallets' `names` (null for no profile), so the first paint shows the name without a swap; a failed read shows no credit and never fails the lane. The credit took the card's "Latest burn" link: the burns stay one step away, on the token page's Boost panel. -- The picker asks only for what it shows: the wallet's own Base launches through `GET /api/launch/list?launcher=0x…`, trending kept a minute across opens. A token an admin keeps out of the lane says "Not shown in the lane" in the picker instead of sending the visitor to a page with no Boost button. +- **What the lane brought**, on the token's page: while it is boosted, the Boost panel adds "312 clicks from the lane in the last 24 hours" (`getTokenBoost` counts the same window as the cards), from 10 clicks up (`BOOST_PANEL_CLICKS_MIN`) so a fresh boost never reads like a small number. A failed count shows no line. +- The picker asks only for what it shows: the wallet's own Base launches through `GET /api/launch/list?launcher=0x…`, trending kept a minute across opens; on the server, a list's first page with no wallet filter shares the live poll's few-second memo (`listPageKey`), so the picker, the ⌘K palette and the launches list's first page cost one query between them. A token an admin keeps out of the lane says "Not shown in the lane" in the picker instead of sending the visitor to a page with no Boost button. ## Views and clicks @@ -64,13 +65,13 @@ Only many addresses can add many clicks: one address adds at most one per card e ## The dialog -On a Base token page (or from **Boost** on a launch in /me, which opens it there): whole TWIG only, presets 100K / 300K / 1M / 5M, the balance, Max (tag included), an estimate in dollars at today's TWIG price, and the last digits the amount will carry. It states plainly that the TWIG goes to 0x…dEaD, cannot be undone or refunded, and counts toward the token's place in the lane for 24 hours without changing the ranking. When the lane is full and the amount would leave the token under the 5th place, the dialog says so before anything is sent and offers the amount that would show it now. The done screen names the token's place, or says it is not in the lane yet. +On a Base token page (or from **Boost** on a launch in /me, which opens it there): whole TWIG only, presets 100K / 300K / 1M / 5M, the balance, Max (tag included), an estimate in dollars at today's TWIG price, and the last digits the amount will carry. It states plainly that the TWIG goes to 0x…dEaD, cannot be undone or refunded, and counts toward the token's place in the lane for 24 hours without changing the ranking. When the lane is full and the amount would leave the token under the 5th place, the dialog says so before anything is sent and offers the amount that would show it now. The done screen names the token's place, or says it is not in the lane yet, and offers **Share on X**: the booster's own post, prefilled with what was sent and where it went ("Boosted $SYM with 1M TWIG on openlaunch.lol. The TWIG went to 0x…dEaD."), the token's page as its link and `via @openlaunch_lol` (`boostShareUrl`, the same x.com intent as the token page's Share). Nothing about price; the words pass the same test as the rest. Writes follow the trade panel: a synchronous lock before the first await, a network switch to Base if needed, a simulation of the exact transfer, then the wallet. **Sent means sent:** once the wallet returns a hash, only a receipt ends the action. A receipt that cannot be read leaves the dialog unconfirmed and locked, with "Check again" and the transaction link; the token page keeps that lock if the dialog is closed and reopened. A reload forgets it, so the copy says to check the wallet's activity before sending again. ## Moderation -ADMIN_WALLETS can hide a token from the lane on /admin (or show it again by address). Each action is an admin-wallet signature over a boost-specific message (`openlaunch.lol boosted lane`, the action, `base:`, the admin, a single-use nonce, a time within 5 minutes), so a posts signature can never be replayed here. Hidden is a flag in `bb_token_settings.boost_hidden`. Burns already sent stay recorded and are never refunded. A hidden token's page says it is not shown in the lane and offers no button, so nobody burns TWIG for a place it cannot get. +ADMIN_WALLETS can hide a token from the lane on /admin (or show it again by address). A token hidden from the panel stays out of its list for 15 seconds, past both machines' few-second lane caches, so a reload that reaches the other machine does not show it again. Each action is an admin-wallet signature over a boost-specific message (`openlaunch.lol boosted lane`, the action, `base:`, the admin, a single-use nonce, a time within 5 minutes), so a posts signature can never be replayed here. Hidden is a flag in `bb_token_settings.boost_hidden`. Burns already sent stay recorded and are never refunded. A hidden token's page says it is not shown in the lane and offers no button, so nobody burns TWIG for a place it cannot get. ## Copy rules From 5b5ffc5bf1e8903a36f4ff5959e8f7a7b3f989a6 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Fri, 16 Oct 2026 16:05:07 +0800 Subject: [PATCH 2/3] fix(boost): review pass on B2 - /admin reads the lane fresh (GET /api/boost?fresh=1&wallet=, skipping both few-second memos), so a reload right after a hide never shows the token again from the other machine's cache. Replaces the 15 s client timer, which brought the stale card back once it ran out. Public reads and polls keep the cached lane. - Undo the shared first-page memo on /api/launch/list: Just Launched fetches the list once per new launch and a 2 s cached page could miss it for good; caller-chosen keys could also crowd the shared memo. The picker's trending stays bounded by its one-minute client cache. - The token page's clicks are the lane card's own count (laneClicks), read only for a token boosted in the window. - One X intent builder: intentUrl(text, { url, via }) in profiles/xpost.ts, used by the booster's post and the token page's Share. --- app/src/app/api/boost/route.ts | 9 ++++-- app/src/app/api/launch/list/route.ts | 8 ++--- app/src/app/api/launch/live/route.ts | 4 +-- app/src/app/t/[chain]/[token]/page.tsx | 3 +- app/src/components/launchpad/BoostAdmin.tsx | 21 +++---------- app/src/components/launchpad/boost-ui.test.ts | 31 ++++++++++--------- app/src/lib/launchpad/boost.ts | 5 +-- app/src/lib/launchpad/boostServer.ts | 26 ++++++++-------- app/src/lib/launchpad/queries.ts | 8 ----- app/src/lib/profiles/profiles.test.ts | 1 + app/src/lib/profiles/xpost.ts | 5 +-- docs/boost.md | 6 ++-- 12 files changed, 55 insertions(+), 72 deletions(-) diff --git a/app/src/app/api/boost/route.ts b/app/src/app/api/boost/route.ts index d2b035ef..61522acd 100644 --- a/app/src/app/api/boost/route.ts +++ b/app/src/app/api/boost/route.ts @@ -4,7 +4,8 @@ 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, boostLaneOrNull, getTokenBoost } from "@/lib/launchpad/boostServer"; +import { boostEnabled, boostLaneOrNull, getBoostLane, getTokenBoost } from "@/lib/launchpad/boostServer"; +import { adminWallets } from "@/lib/launchpad/postsServer"; export const dynamic = "force-dynamic"; @@ -27,8 +28,10 @@ export async function GET(req: Request) { if (!isAddress(token)) return NextResponse.json({ error: "bad token" }, { status: 400 }); return NextResponse.json(await getTokenBoost(token), { headers: NO_STORE }); } - // the same few-second cache pages and polls read: a caller polling this cannot add a database pass per request - const lane = await boostLaneOrNull(await ethUsd()); + // the same few-second cache pages and polls read: a caller polling this cannot add a database pass per request. + // An admin wallet may ask for it fresh (the moderation panel after a hide), like the reported-posts queue's gate. + const fresh = u.searchParams.get("fresh") === "1" && adminWallets().has((u.searchParams.get("wallet") ?? "").toLowerCase()); + const lane = fresh ? await getBoostLane(await ethUsd(), { fresh: true }) : await boostLaneOrNull(await ethUsd()); if (!lane) return NextResponse.json({ error: "could not load boosts" }, { status: 502 }); return NextResponse.json(lane, { headers: NO_STORE }); } catch (err) { diff --git a/app/src/app/api/launch/list/route.ts b/app/src/app/api/launch/list/route.ts index 4e12b01f..d72259d4 100644 --- a/app/src/app/api/launch/list/route.ts +++ b/app/src/app/api/launch/list/route.ts @@ -3,8 +3,7 @@ import { isAddress } from "viem"; import { visibleChainOr } from "@/lib/launchpad/config"; import { isFilter } from "@/lib/launchpad/search"; import { clampLimit, clampOffset } from "@/lib/launchpad/paging"; -import { VOLUME_WINDOWS, listLaunchesPage, listPageKey, parseSort, type VolumeWindow } from "@/lib/launchpad/queries"; -import { memo } from "@/lib/launchpad/memo"; +import { VOLUME_WINDOWS, listLaunchesPage, parseSort, type VolumeWindow } from "@/lib/launchpad/queries"; import { ethUsd } from "@/lib/launchpad/ethPrice"; export const dynamic = "force-dynamic"; @@ -28,10 +27,7 @@ export async function GET(req: Request) { const launcher = l && isAddress(l) ? l.toLowerCase() : undefined; try { const usd = await ethUsd(); - const opts = { sort, window, chain, filter, limit, offset, launcher, ethUsd: usd }; - // a first page with no wallet filter shares the live poll's few-second memo (same key): the picker's trending, the - // palette and the launches list's first page cost one query between them; a paged or per-wallet read is never shared - const page = offset === 0 && !launcher ? await memo(listPageKey(opts), 2_000, () => listLaunchesPage(opts)) : await listLaunchesPage(opts); + const page = await listLaunchesPage({ sort, window, chain, filter, limit, offset, launcher, ethUsd: usd }); return NextResponse.json({ sort, window, chain, filter, limit, offset, has_more: page.hasMore, ethUsd: usd, launches: page.items }, { headers: { "cache-control": "no-store" } }); } catch (err) { console.error("[launch] list failed:", err instanceof Error ? err.message : err); diff --git a/app/src/app/api/launch/live/route.ts b/app/src/app/api/launch/live/route.ts index 280bffad..cc32155a 100644 --- a/app/src/app/api/launch/live/route.ts +++ b/app/src/app/api/launch/live/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { visibleChainOr } from "@/lib/launchpad/config"; import { isFilter } from "@/lib/launchpad/search"; import { clampLimit } from "@/lib/launchpad/paging"; -import { VOLUME_WINDOWS, getLaunchFeed, getLaunchTotals, getTrending, isTrendingSource, listLaunchesPage, listPageKey, parseSort, trendingFrom, type VolumeWindow } from "@/lib/launchpad/queries"; +import { VOLUME_WINDOWS, getLaunchFeed, getLaunchTotals, getTrending, isTrendingSource, listLaunchesPage, parseSort, trendingFrom, type VolumeWindow } from "@/lib/launchpad/queries"; import { ethUsd } from "@/lib/launchpad/ethPrice"; import { memo } from "@/lib/launchpad/memo"; import { listFeed } from "@/lib/launchpad/postsServer"; @@ -36,7 +36,7 @@ export async function GET(req: Request) { 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(listPageKey(listOpts), 2_000, () => listLaunchesPage(listOpts)) : Promise.resolve(null), + 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 diff --git a/app/src/app/t/[chain]/[token]/page.tsx b/app/src/app/t/[chain]/[token]/page.tsx index d40eebba..0e56d295 100644 --- a/app/src/app/t/[chain]/[token]/page.tsx +++ b/app/src/app/t/[chain]/[token]/page.tsx @@ -19,6 +19,7 @@ import TokenTrades from "@/components/launchpad/TokenTrades"; import LaunchReceipt from "@/components/launchpad/LaunchReceipt"; import TokenAbout from "@/components/launchpad/TokenAbout"; import NamesProvider from "@/components/profile/NamesProvider"; +import { intentUrl } from "@/lib/profiles/xpost"; import { namesFor, type NameEntry } from "@/lib/profiles/server"; import { getHolderPanel } from "@/lib/launchpad/holdersServer"; import { memo } from "@/lib/launchpad/memo"; @@ -220,7 +221,7 @@ export default async function TokenPage({ params, searchParams }: { params: Prom {/* the token's links as one bar, the Watch button's height: share, copy the address, the explorer */}
- + {/* phones keep one row of actions; the explorer link is also in About & contracts */} {explorerName(chain)} diff --git a/app/src/components/launchpad/BoostAdmin.tsx b/app/src/components/launchpad/BoostAdmin.tsx index bf51ad31..fa81c0af 100644 --- a/app/src/components/launchpad/BoostAdmin.tsx +++ b/app/src/components/launchpad/BoostAdmin.tsx @@ -19,27 +19,22 @@ import { nonce } from "@/lib/nonce"; * 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. Below them, the lane's views and clicks per token. */ -/** How long a token hidden from this panel stays out of its list, past both machines' lane caches. */ -const JUST_HIDDEN_MS = 15_000; - 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 token hidden here stays out of the list for a while: the reload after a hide can reach the other machine, whose - // lane cache (two stacked few-second memos) may still hold it for up to ~10 seconds - const [justHidden, setJustHidden] = useState>(new Set()); // a failed or unreadable answer (a proxy's HTML page) keeps the last list on screen; it never throws from the effect + // an admin wallet reads the lane fresh: right after a hide the other machine's few-second caches may still hold it const load = useCallback(async () => { try { - const res = await fetch(`/api/boost?chain=${BOOST_CHAIN}`, { cache: "no-store" }); + const res = await fetch(`/api/boost?chain=${BOOST_CHAIN}${address ? `&fresh=1&wallet=${address}` : ""}`, { cache: "no-store" }); if (res.ok) setLane((await res.json()) as BoostLane); } catch { // keep the last lane } - }, []); + }, [address]); useEffect(() => { const id = setTimeout(() => void load(), 0); return () => clearTimeout(id); @@ -57,14 +52,6 @@ export default function BoostAdmin() { 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}` }); - const t = token.toLowerCase(); - setJustHidden((s) => { - const next = new Set(s); - if (action === "hide") next.add(t); - else next.delete(t); - return next; - }); - if (action === "hide") setTimeout(() => setJustHidden((s) => (s.has(t) ? new Set([...s].filter((x) => x !== t)) : s)), JUST_HIDDEN_MS); // 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(); @@ -80,7 +67,7 @@ export default function BoostAdmin() { {!address ?

Connect the admin wallet.

: null} {lane?.enabled && lane.items.length === 0 ?

Nothing is boosted right now.

: null} {lane?.enabled - ? lane.items.filter((b) => !justHidden.has(b.token)).map((b) => ( + ? lane.items.map((b) => (
{b.launch.name} diff --git a/app/src/components/launchpad/boost-ui.test.ts b/app/src/components/launchpad/boost-ui.test.ts index 56a7e003..12331116 100644 --- a/app/src/components/launchpad/boost-ui.test.ts +++ b/app/src/components/launchpad/boost-ui.test.ts @@ -345,9 +345,14 @@ test("the lane invites a boost: a header button, every open place a button, and assert.match(server, /items, names: await creditNames\(\[\.\.\.boosters\.values\(\)\]\) \};/); assert.match(server, /const known = await namesFor\(wallets\)\.catch\(\(\) => 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/); + // a public read of the lane comes from the same few-second cache: polling it adds no database pass; only an admin + // wallet gets it fresh (the moderation panel after a hide) + assert.match(boostRoute, /const fresh = u\.searchParams\.get\("fresh"\) === "1" && adminWallets\(\)\.has\(\(u\.searchParams\.get\("wallet"\) \?\? ""\)\.toLowerCase\(\)\);/); + assert.match(boostRoute, /const lane = fresh \? await getBoostLane\(await ethUsd\(\), \{ fresh: true \}\) : await boostLaneOrNull\(await ethUsd\(\)\);/); + assert.equal((boostRoute.match(/getBoostLane\(/g) ?? []).length, 1); + assert.match(server, /const ranked = opts\.fresh \? rankLane\(await laneTotals\(BOOST_LANE_SIZE \* 4\), BOOST_LANE_SIZE \* 4\) : await rankedLane\(\);/); + assert.match(admin, /fetch\(`\/api\/boost\?chain=\$\{BOOST_CHAIN\}\$\{address \? `&fresh=1&wallet=\$\{address\}` : ""\}`/); + assert.doesNotMatch(admin, /justHidden|JUST_HIDDEN/, "no timer guess: the admin's read is simply fresh"); }); test("the picker only navigates: the boost happens on the token's page, with every check the dialog has", () => { @@ -383,15 +388,15 @@ test("a card credits the wallet that sent the most TWIG for it; a failed read sh assert.match(lane, /\{b\.top_booster \? \(/); }); -const liveRouteSrc = read("../../app/api/launch/live/route.ts"); test("the token page shows what the lane brought: clicks in the same 24 hours, from 10 up, only while boosted", () => { assert.match(panel, /const clicks = state\.clicks \?\? 0;/); assert.match(panel, /\{active && clicks >= BOOST_PANEL_CLICKS_MIN \? \(/); assert.match(panel, /clicks from the lane in the last 24 hours/); + // the lane card's own count (laneClicks: same window, never throws), and only for a token boosted in the window const body = server.slice(server.indexOf("export async function getTokenBoost("), server.indexOf("/* ─────────────────────────────── views and clicks")); - assert.match(body, /SELECT count\(\*\)::int AS clicks FROM bb_boost_clicks\n\s*WHERE chain_id = \$\{BOOST_CHAIN_ID\} AND token = \$\{t\}\n\s*AND clicked_at > now\(\) - make_interval\(hours => \$\{BOOST_WINDOW_H\}\)`\.catch\(/); - assert.match(body, /return \[\{ clicks: 0 \}\];/, "a failed count is no line, never a panel that fails"); + assert.match(body, /const clicks = row\?\.last_at \? \(\(await laneClicks\(\[t\]\)\)\.get\(t\) \?\? 0\) : 0;/); + assert.doesNotMatch(body, /FROM bb_boost_clicks/, "one click count for the card and the page, not two copies"); }); test("after a confirmed boost the booster can share it on X: its own post, in a new tab", () => { @@ -405,14 +410,10 @@ test("the agents' notes name the lane's fields as they are", () => { assert.doesNotMatch(llms, /launch; entry\)/); }); -test("a list's first page with no wallet filter shares the live poll's few-second memo; paged and per-wallet reads never do", () => { - assert.match(list, /const page = offset === 0 && !launcher \? await memo\(listPageKey\(opts\), 2_000, \(\) => listLaunchesPage\(opts\)\) : await listLaunchesPage\(opts\);/); - assert.match(liveRouteSrc, /listOpts \? memo\(listPageKey\(listOpts\), 2_000, \(\) => listLaunchesPage\(listOpts\)\)/); - assert.match(queries, /export function listPageKey\(o: Pick\): string \{\n\s*return `list:\$\{o\.chain \?\? "all"\}:\$\{o\.sort\}:\$\{o\.window\}:\$\{o\.filter \?\? "-"\}:\$\{o\.limit\}`;/); -}); -test("a token hidden from the admin panel stays out of its list past both machines' lane caches", () => { - assert.match(admin, /const JUST_HIDDEN_MS = 15_000;/); - assert.match(admin, /lane\.items\.filter\(\(b\) => !justHidden\.has\(b\.token\)\)\.map\(/); - assert.match(admin, /if \(action === "hide"\) next\.add\(t\);\n\s*else next\.delete\(t\);/); + +test("one X intent builder: the booster's post and the token page's Share both go through intentUrl", () => { + assert.match(read("../../lib/launchpad/boost.ts"), /return intentUrl\(boostShareText\(symbol, raw\), \{ url: pageUrl, via: BRAND_X \}\);/); + assert.match(tokenPage, / [w, known[w] ?? null])) : {}; } -/** The Boosted lane: the top BOOST_LANE_SIZE tokens with their market rows. `{ enabled: false }` while the switch is off. */ -export async function getBoostLane(ethUsd: number | null): Promise { +/** + * The Boosted lane: the top BOOST_LANE_SIZE tokens with their market rows. `{ enabled: false }` while the switch is off. + * `fresh` skips the few-second ranking memo too (the admin panel after a hide: the other machine's memos may still hold + * the token); pages and polls read it through boostLaneOrNull instead. + */ +export async function getBoostLane(ethUsd: number | null, opts: { fresh?: boolean } = {}): Promise { if (!boostEnabled()) return { enabled: false }; + const ranked = opts.fresh ? rankLane(await laneTotals(BOOST_LANE_SIZE * 4), BOOST_LANE_SIZE * 4) : await rankedLane(); // a few spare candidates, so a token whose market row cannot be read leaves its place to the next one, not a hole - const top = (await rankedLane()).slice(0, BOOST_LANE_SIZE + 3); + const top = ranked.slice(0, BOOST_LANE_SIZE + 3); const rows = top.length > 0 ? await getLaunchesByRefs(top.map((t) => ({ chain: BOOST_CHAIN, token: t.token })), ethUsd) : []; const byToken = new Map(rows.map((r) => [r.launch.token, r.launch])); const shown = top @@ -199,7 +204,7 @@ export async function getTokenBoost(token: string): Promise { const t = token.toLowerCase(); const none = { enabled: true as const, hidden: false, twig: "0", boosts: 0, boosters: 0, last_at: null, last_tx: null, expires_at: null, place: null, entry: { full: false, cutoff: null }, clicks: 0 }; if (!db) return none; - const [[row], [setting], ranked, [seen]] = await Promise.all([ + const [[row], [setting], ranked] = await Promise.all([ db<{ twig: string; boosts: number; boosters: number; last_at: string | null; last_tx: string | null }[]>` SELECT COALESCE(sum(b.amount), 0)::text AS twig, count(*)::int AS boosts, count(DISTINCT b.booster)::int AS boosters, max(b.block_time) AS last_at, @@ -211,15 +216,10 @@ export async function getTokenBoost(token: string): Promise { AND b.amount >= ${RAW_FLOOR}::numeric`, db<{ boost_hidden: boolean }[]>`SELECT boost_hidden FROM bb_token_settings WHERE chain_id = ${BOOST_CHAIN_ID} AND token = ${t}`, rankedLane(), - // the lane's own window; a failed count shows no clicks line, never a panel that fails - db<{ clicks: number }[]>` - SELECT count(*)::int AS clicks FROM bb_boost_clicks - WHERE chain_id = ${BOOST_CHAIN_ID} AND token = ${t} - AND clicked_at > now() - make_interval(hours => ${BOOST_WINDOW_H})`.catch((err) => { - console.warn("[boost] token clicks read failed:", err instanceof Error ? err.message : err); - return [{ clicks: 0 }]; - }), ]); + // the lane's own count (same window, same rows as the card); only for a token boosted in the window, since the panel + // shows nothing otherwise. laneClicks never throws: a failed read is no clicks line. + const clicks = row?.last_at ? ((await laneClicks([t])).get(t) ?? 0) : 0; const i = ranked.findIndex((r) => r.token === t); return { enabled: true, @@ -232,7 +232,7 @@ export async function getTokenBoost(token: string): Promise { expires_at: row?.last_at ? new Date(boostExpiresAt(row.last_at)).toISOString() : null, place: i >= 0 && i < BOOST_LANE_SIZE ? i + 1 : null, entry: laneEntry(ranked), - clicks: seen?.clicks ?? 0, + clicks, }; } diff --git a/app/src/lib/launchpad/queries.ts b/app/src/lib/launchpad/queries.ts index 244d5836..2b3f36ab 100644 --- a/app/src/lib/launchpad/queries.ts +++ b/app/src/lib/launchpad/queries.ts @@ -308,14 +308,6 @@ export type ListPage = { items: LaunchRow[]; hasMore: boolean }; * Outside tier 0 a launcher keeps only its newest row, and that row carries how many of its siblings were folded * (`launcher_collapsed`). The tier travels with the row (`live_tier`) so the client never recomputes it. */ -/** - * The few-second memo key for a list's first page, shared by the live poll (api/launch/live) and api/launch/list, so - * the same view asked through either is one query. Callers memoize only a first page with no wallet filter. - */ -export function listPageKey(o: Pick): string { - return `list:${o.chain ?? "all"}:${o.sort}:${o.window}:${o.filter ?? "-"}:${o.limit}`; -} - export async function listLaunchesPage(opts: ListOpts = {}): Promise { const db = maybeDb(); if (!db) return { items: [], hasMore: false }; diff --git a/app/src/lib/profiles/profiles.test.ts b/app/src/lib/profiles/profiles.test.ts index 0985faa2..d8dbd9ac 100644 --- a/app/src/lib/profiles/profiles.test.ts +++ b/app/src/lib/profiles/profiles.test.ts @@ -110,6 +110,7 @@ test("post texts carry the code, the brand account and the profile link; the pic } assert.equal(postTextFor("kevin", "OL-7K2QXM9A", "openlaunch_lol", "openlaunch.lol"), postTextFor("kevin", "OL-7K2QXM9A", "openlaunch_lol", "openlaunch.lol")); assert.match(intentUrl("a b\nc"), /^https:\/\/x\.com\/intent\/post\?text=a%20b%0Ac$/); + assert.equal(intentUrl("hi", { url: "https://openlaunch.lol/t/base/0xabc?x=1&y=2", via: "openlaunch_lol" }), "https://x.com/intent/post?text=hi&url=https%3A%2F%2Fopenlaunch.lol%2Ft%2Fbase%2F0xabc%3Fx%3D1%26y%3D2&via=openlaunch_lol"); }); test("oEmbed parsing: text out of the blockquote, author from author_url", () => { diff --git a/app/src/lib/profiles/xpost.ts b/app/src/lib/profiles/xpost.ts index 36b0f95e..2eb5c769 100644 --- a/app/src/lib/profiles/xpost.ts +++ b/app/src/lib/profiles/xpost.ts @@ -82,8 +82,9 @@ export function postTextFor(username: string, code: string, brandX: string, doma } /** X's compose link with the post text filled in. */ -export function intentUrl(text: string): string { - return `https://x.com/intent/post?text=${encodeURIComponent(text)}`; +export function intentUrl(text: string, extra: { url?: string; via?: string } = {}): string { + const tail = `${extra.url ? `&url=${encodeURIComponent(extra.url)}` : ""}${extra.via ? `&via=${encodeURIComponent(extra.via)}` : ""}`; + return `https://x.com/intent/post?text=${encodeURIComponent(text)}${tail}`; } /** The token X's own embed widget sends with tweet-result requests. */ diff --git a/docs/boost.md b/docs/boost.md index 4c096be1..c222cb64 100644 --- a/docs/boost.md +++ b/docs/boost.md @@ -44,8 +44,8 @@ The lane asks for a boost where people see it, without ever sending one itself: - **"Boost a token"** in the lane's header, on every screen size. **Every open place is a button** ("+ Open spot · from 100K TWIG", one per place from 1024px; one "+ N open spots · Boost a token" row below that, so the cards stay first). Each opens the **picker** (`BoostPicker.tsx`, loaded on first use): the connected wallet's own Base launches first (their creators are the likeliest boosters), a search of Base tokens, or what is trending on Base before anything is typed. Picking a token goes to `/t/base/?boost=1#boost`, the same link /me's Boost uses, where the dialog opens with every check it has. Nothing is sent from the lane or the picker. - **What it takes to get in**, beside the title (under it on a phone): "2 open spots · from 100K TWIG" while there is room, "Full · 5th place holds 1.2M TWIG" once there is not. It is read off the cards the lane shows (`boostEntryLine`), so it never contradicts them. The token page's dialog keeps its own rule from the full ranking; the two differ only when a ranked token's market row cannot be read and its card is left out. - **Credit:** each card names the wallet that sent the most TWIG for it in the last 24 hours ("by @name ✓" with a profile, the short address otherwise; a tie goes to the newer boost, then the address). Only the name is a link, above the card's own. The lane read carries `top_booster` and the credited wallets' `names` (null for no profile), so the first paint shows the name without a swap; a failed read shows no credit and never fails the lane. The credit took the card's "Latest burn" link: the burns stay one step away, on the token page's Boost panel. -- **What the lane brought**, on the token's page: while it is boosted, the Boost panel adds "312 clicks from the lane in the last 24 hours" (`getTokenBoost` counts the same window as the cards), from 10 clicks up (`BOOST_PANEL_CLICKS_MIN`) so a fresh boost never reads like a small number. A failed count shows no line. -- The picker asks only for what it shows: the wallet's own Base launches through `GET /api/launch/list?launcher=0x…`, trending kept a minute across opens; on the server, a list's first page with no wallet filter shares the live poll's few-second memo (`listPageKey`), so the picker, the ⌘K palette and the launches list's first page cost one query between them. A token an admin keeps out of the lane says "Not shown in the lane" in the picker instead of sending the visitor to a page with no Boost button. +- **What the lane brought**, on the token's page: while it is boosted, the Boost panel adds "312 clicks from the lane in the last 24 hours" (`getTokenBoost` reuses the cards' own count, only for a token boosted in the window), from 10 clicks up (`BOOST_PANEL_CLICKS_MIN`) so a fresh boost never reads like a small number. A failed count shows no line. +- The picker asks only for what it shows: the wallet's own Base launches through `GET /api/launch/list?launcher=0x…`, trending kept a minute across opens. A token an admin keeps out of the lane says "Not shown in the lane" in the picker instead of sending the visitor to a page with no Boost button. ## Views and clicks @@ -71,7 +71,7 @@ Writes follow the trade panel: a synchronous lock before the first await, a netw ## Moderation -ADMIN_WALLETS can hide a token from the lane on /admin (or show it again by address). A token hidden from the panel stays out of its list for 15 seconds, past both machines' few-second lane caches, so a reload that reaches the other machine does not show it again. Each action is an admin-wallet signature over a boost-specific message (`openlaunch.lol boosted lane`, the action, `base:`, the admin, a single-use nonce, a time within 5 minutes), so a posts signature can never be replayed here. Hidden is a flag in `bb_token_settings.boost_hidden`. Burns already sent stay recorded and are never refunded. A hidden token's page says it is not shown in the lane and offers no button, so nobody burns TWIG for a place it cannot get. +ADMIN_WALLETS can hide a token from the lane on /admin (or show it again by address). The panel reads the lane fresh (`GET /api/boost?chain=base&fresh=1&wallet=`, skipping both few-second caches), so a reload right after a hide never shows the token again from the other machine's cache. Pages and polls keep reading the cached lane, so a hidden card can stay on an open page for up to about 10 seconds. Each action is an admin-wallet signature over a boost-specific message (`openlaunch.lol boosted lane`, the action, `base:`, the admin, a single-use nonce, a time within 5 minutes), so a posts signature can never be replayed here. Hidden is a flag in `bb_token_settings.boost_hidden`. Burns already sent stay recorded and are never refunded. A hidden token's page says it is not shown in the lane and offers no button, so nobody burns TWIG for a place it cannot get. ## Copy rules From c4a749d5cfca9c146724391efab82f92eb7845a5 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Fri, 16 Oct 2026 16:19:23 +0800 Subject: [PATCH 3/3] fix(boost): the fresh lane read comes only with a signed moderation answer CodeRabbit on #102: `GET /api/boost?fresh=1&wallet=` trusted a wallet named in the URL, so anyone who knew an admin address could skip both lane caches. The public read no longer has a fresh path at all. Instead the signed, single-use moderation request (POST /api/boost/mod, admin signature + nonce, already verified before any write) answers with the lane read fresh, and the admin panel shows that lane at once; if that read fails the write still stands, the card still leaves at once and a cached read follows. Verified locally with signed hide/unhide requests; a forged one gets 401. --- app/src/app/api/boost/mod/route.ts | 16 +++++++++++--- app/src/app/api/boost/route.ts | 8 +++---- app/src/components/launchpad/BoostAdmin.tsx | 18 +++++++++------- app/src/components/launchpad/boost-ui.test.ts | 21 ++++++++++++------- docs/boost.md | 2 +- 5 files changed, 41 insertions(+), 24 deletions(-) diff --git a/app/src/app/api/boost/mod/route.ts b/app/src/app/api/boost/mod/route.ts index f5c41a26..59a2c192 100644 --- a/app/src/app/api/boost/mod/route.ts +++ b/app/src/app/api/boost/mod/route.ts @@ -1,16 +1,26 @@ import { NextResponse } from "next/server"; import { rateLimited } from "@/lib/launchpad/editServer"; import { clientIp, readJson } from "@/lib/profiles/http"; -import { moderateBoost } from "@/lib/launchpad/boostServer"; +import { getBoostLane, moderateBoost } from "@/lib/launchpad/boostServer"; +import { ethUsd } from "@/lib/launchpad/ethPrice"; 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. */ +/** + * 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. A signed, single-use request, so its answer may carry the lane read fresh (both few-second + * caches skipped): the panel shows the result at once, whichever machine its next read reaches. `lane` is null when + * that read fails; the write itself has succeeded. + */ 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); + const lane = await getBoostLane(await ethUsd(), { fresh: true }).catch((err) => { + console.warn("[boost] fresh lane after moderation failed:", err instanceof Error ? err.message : err); + return null; + }); + return NextResponse.json({ ...r, lane }); } diff --git a/app/src/app/api/boost/route.ts b/app/src/app/api/boost/route.ts index 61522acd..450f4857 100644 --- a/app/src/app/api/boost/route.ts +++ b/app/src/app/api/boost/route.ts @@ -4,8 +4,7 @@ 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, boostLaneOrNull, getBoostLane, getTokenBoost } from "@/lib/launchpad/boostServer"; -import { adminWallets } from "@/lib/launchpad/postsServer"; +import { boostEnabled, boostLaneOrNull, getTokenBoost } from "@/lib/launchpad/boostServer"; export const dynamic = "force-dynamic"; @@ -29,9 +28,8 @@ export async function GET(req: Request) { return NextResponse.json(await getTokenBoost(token), { headers: NO_STORE }); } // the same few-second cache pages and polls read: a caller polling this cannot add a database pass per request. - // An admin wallet may ask for it fresh (the moderation panel after a hide), like the reported-posts queue's gate. - const fresh = u.searchParams.get("fresh") === "1" && adminWallets().has((u.searchParams.get("wallet") ?? "").toLowerCase()); - const lane = fresh ? await getBoostLane(await ethUsd(), { fresh: true }) : await boostLaneOrNull(await ethUsd()); + // The only fresh read is the signed moderation answer (api/boost/mod), right after a hide or unhide. + const lane = await boostLaneOrNull(await ethUsd()); if (!lane) return NextResponse.json({ error: "could not load boosts" }, { status: 502 }); return NextResponse.json(lane, { headers: NO_STORE }); } catch (err) { diff --git a/app/src/components/launchpad/BoostAdmin.tsx b/app/src/components/launchpad/BoostAdmin.tsx index fa81c0af..6ba66c2c 100644 --- a/app/src/components/launchpad/BoostAdmin.tsx +++ b/app/src/components/launchpad/BoostAdmin.tsx @@ -26,15 +26,14 @@ export default function BoostAdmin() { 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 - // an admin wallet reads the lane fresh: right after a hide the other machine's few-second caches may still hold it const load = useCallback(async () => { try { - const res = await fetch(`/api/boost?chain=${BOOST_CHAIN}${address ? `&fresh=1&wallet=${address}` : ""}`, { cache: "no-store" }); + 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 } - }, [address]); + }, []); useEffect(() => { const id = setTimeout(() => void load(), 0); return () => clearTimeout(id); @@ -49,12 +48,17 @@ export default function BoostAdmin() { 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 }; + const d = (await res.json().catch(() => ({}))) as { error?: string; lane?: BoostLane | null }; 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(); + // the signed answer carries the lane read fresh, so the list is right at once, whichever machine answers next; + // without it (that read failed) the card still leaves at once and a cached read follows + if (d.lane) { + setLane(d.lane); + } else { + 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) }); } diff --git a/app/src/components/launchpad/boost-ui.test.ts b/app/src/components/launchpad/boost-ui.test.ts index 12331116..581c9e53 100644 --- a/app/src/components/launchpad/boost-ui.test.ts +++ b/app/src/components/launchpad/boost-ui.test.ts @@ -189,7 +189,7 @@ test("CodeRabbit round 1: the done screen, admin errors and the agent formula", assert.doesNotMatch(dialog, /settle\([^)]*, true\)/); assert.match(dialog, /const showsAfter = \(w: bigint\): boolean => !tagOk \|\| boostShowsNow\(entry, current, boostAmount\(w, tag\), inLane\);/); // an error page that is not JSON still reports the HTTP failure; a bad lane read never throws from the effect - assert.match(admin, /const d = \(await res\.json\(\)\.catch\(\(\) => \(\{\}\)\)\) as \{ error\?: string \};/); + assert.match(admin, /const d = \(await res\.json\(\)\.catch\(\(\) => \(\{\}\)\)\) as \{ error\?: string; lane\?: BoostLane \| null \};/); assert.match(admin, /throw new Error\(d\.error \?\? `request failed \(HTTP \$\{res\.status\}\)`\);/); assert.match(admin, /const load = useCallback\(async \(\) => \{\n\s*try \{/); // agents get the bounds the decoder enforces (isLaunchTag: 0 < tag < 10^11; whole TWIG >= the floor) @@ -345,14 +345,19 @@ test("the lane invites a boost: a header button, every open place a button, and assert.match(server, /items, names: await creditNames\(\[\.\.\.boosters\.values\(\)\]\) \};/); assert.match(server, /const known = await namesFor\(wallets\)\.catch\(\(\) => 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; only an admin - // wallet gets it fresh (the moderation panel after a hide) - assert.match(boostRoute, /const fresh = u\.searchParams\.get\("fresh"\) === "1" && adminWallets\(\)\.has\(\(u\.searchParams\.get\("wallet"\) \?\? ""\)\.toLowerCase\(\)\);/); - assert.match(boostRoute, /const lane = fresh \? await getBoostLane\(await ethUsd\(\), \{ fresh: true \}\) : await boostLaneOrNull\(await ethUsd\(\)\);/); - assert.equal((boostRoute.match(/getBoostLane\(/g) ?? []).length, 1); + // a public read of the lane comes from the same few-second cache: polling it adds no database pass, and no query + // parameter skips it (CodeRabbit #102: a wallet in the URL is not proof of an admin) + assert.match(boostRoute, /const lane = await boostLaneOrNull\(await ethUsd\(\)\);/); + assert.doesNotMatch(boostRoute, /getBoostLane|searchParams\.get\("fresh"\)|adminWallets/); + // the only fresh read is the answer to a signed, single-use moderation request, after the write + const modRoute = read("../../app/api/boost/mod/route.ts"); + assert.ok(modRoute.indexOf("if (!r.ok) return") < modRoute.indexOf("getBoostLane(await ethUsd(), { fresh: true })"), "fresh only after a verified write"); + assert.match(modRoute, /return NextResponse\.json\(\{ \.\.\.r, lane \}\);/); assert.match(server, /const ranked = opts\.fresh \? rankLane\(await laneTotals\(BOOST_LANE_SIZE \* 4\), BOOST_LANE_SIZE \* 4\) : await rankedLane\(\);/); - assert.match(admin, /fetch\(`\/api\/boost\?chain=\$\{BOOST_CHAIN\}\$\{address \? `&fresh=1&wallet=\$\{address\}` : ""\}`/); - assert.doesNotMatch(admin, /justHidden|JUST_HIDDEN/, "no timer guess: the admin's read is simply fresh"); + // the panel takes that lane; its own reads are the cached ones everyone gets + assert.match(admin, /if \(d\.lane\) \{\n\s*setLane\(d\.lane\);\n\s*\} else \{/); + assert.match(admin, /fetch\(`\/api\/boost\?chain=\$\{BOOST_CHAIN\}`, \{ cache: "no-store" \}\)/); + assert.doesNotMatch(admin, /fresh=1|justHidden|JUST_HIDDEN/); }); test("the picker only navigates: the boost happens on the token's page, with every check the dialog has", () => { diff --git a/docs/boost.md b/docs/boost.md index c222cb64..cea20739 100644 --- a/docs/boost.md +++ b/docs/boost.md @@ -71,7 +71,7 @@ Writes follow the trade panel: a synchronous lock before the first await, a netw ## Moderation -ADMIN_WALLETS can hide a token from the lane on /admin (or show it again by address). The panel reads the lane fresh (`GET /api/boost?chain=base&fresh=1&wallet=`, skipping both few-second caches), so a reload right after a hide never shows the token again from the other machine's cache. Pages and polls keep reading the cached lane, so a hidden card can stay on an open page for up to about 10 seconds. Each action is an admin-wallet signature over a boost-specific message (`openlaunch.lol boosted lane`, the action, `base:`, the admin, a single-use nonce, a time within 5 minutes), so a posts signature can never be replayed here. Hidden is a flag in `bb_token_settings.boost_hidden`. Burns already sent stay recorded and are never refunded. A hidden token's page says it is not shown in the lane and offers no button, so nobody burns TWIG for a place it cannot get. +ADMIN_WALLETS can hide a token from the lane on /admin (or show it again by address). The signed moderation answer carries the lane read fresh (both few-second caches skipped), so the panel is right at once, whichever machine its next read reaches. No public read skips the caches: a wallet named in a URL proves nothing. Pages and polls keep reading the cached lane, so a hidden card can stay on an open page for up to about 10 seconds. Each action is an admin-wallet signature over a boost-specific message (`openlaunch.lol boosted lane`, the action, `base:`, the admin, a single-use nonce, a time within 5 minutes), so a posts signature can never be replayed here. Hidden is a flag in `bb_token_settings.boost_hidden`. Burns already sent stay recorded and are never refunded. A hidden token's page says it is not shown in the lane and offers no button, so nobody burns TWIG for a place it cannot get. ## Copy rules