From 103bcb93bae90c666fa781701d13c6e910f64265 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Thu, 15 Oct 2026 09:11:13 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(boost):=20TWIG=20sent=20to=200x?= =?UTF-8?q?=E2=80=A6dEaD=20buys=20a=20place=20in=20a=20labelled=20Boosted?= =?UTF-8?q?=20lane?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A boost is one plain TWIG.transfer(0x…dEaD, amount) on Base whose amount names the launch in its last 12 digits (whole TWIG x 10^18 + the launch's position id). The tag lives in the Transfer event, so the indexer reads logs only and plain wallets, smart wallets, Safes and agents all boost the same way. No new contract, no approval, nothing paid to openlaunch or Twigpine. - lib/launchpad/boost.ts: the rule (floor 100K TWIG, 24h window, lane of 5), amount encode/decode, Max under a balance, Transfer decoder, lane order, time left, moderation message, and every sentence the feature shows - indexer: one filtered log query per Base range plus the receipt path; bb_boosts holds raw facts, the locker's own fee burns are skipped - boostServer + GET /api/boost, POST /api/boost/mod (admin-signed hide/unhide) - the lane rides on the live poll; home page and /base under the Trending board; token page panel and dialog; /me link; ⌘K entry; admin section; rules FAQ entry; llms.txt section; hero "TWIG burned" adds boost burns - dialog: lock before the first await, Base switch, simulate, builder code; sent means sent (an unread receipt locks it with "Check again") - the Live sort, Trending and ranking.ts never read boosts (guard test) - ships dark: everything is off unless BOOST=on Verified on an anvil Base fork with real transfers: the receipt and range paths record exactly the tagged boost and skip under-floor, untagged and locker burns; migration is idempotent on a dev copy and an empty database. --- app/.env.example | 3 + app/db/concurrent-indexes.sql | 2 + app/db/schema.sql | 19 ++ app/src/app/(home)/page.tsx | 7 +- app/src/app/admin/page.tsx | 4 +- app/src/app/api/boost/mod/route.ts | 16 ++ app/src/app/api/boost/route.ts | 35 +++ app/src/app/api/launch/live/route.ts | 9 +- app/src/app/llms.txt/route.ts | 18 ++ app/src/app/rules/page.tsx | 3 + app/src/app/t/[chain]/[token]/page.tsx | 20 +- app/src/components/CommandPalette.tsx | 5 +- app/src/components/launchpad/BoostAdmin.tsx | 82 +++++++ app/src/components/launchpad/BoostDialog.tsx | 222 ++++++++++++++++++ app/src/components/launchpad/BoostPanel.tsx | 69 ++++++ .../components/launchpad/BoostRulesEntry.tsx | 16 ++ app/src/components/launchpad/BoostedLane.tsx | 97 ++++++++ app/src/components/launchpad/ChainLanding.tsx | 8 +- .../components/launchpad/LaunchMechanism.tsx | 9 +- app/src/components/launchpad/LiveProvider.tsx | 3 + app/src/components/launchpad/MeDashboard.tsx | 11 +- app/src/components/launchpad/boost-ui.test.ts | 131 +++++++++++ app/src/components/launchpad/useBoostOn.ts | 22 ++ app/src/lib/command-palette.ts | 7 +- app/src/lib/launchpad/abi.ts | 14 ++ app/src/lib/launchpad/boost.test.ts | 179 ++++++++++++++ app/src/lib/launchpad/boost.ts | 191 +++++++++++++++ app/src/lib/launchpad/boostServer.ts | 153 ++++++++++++ app/src/lib/launchpad/indexer.ts | 50 +++- app/src/lib/launchpad/postsServer.ts | 8 +- app/src/lib/launchpad/queries.ts | 23 +- docs/boost.md | 60 +++++ 32 files changed, 1469 insertions(+), 27 deletions(-) create mode 100644 app/src/app/api/boost/mod/route.ts create mode 100644 app/src/app/api/boost/route.ts create mode 100644 app/src/components/launchpad/BoostAdmin.tsx create mode 100644 app/src/components/launchpad/BoostDialog.tsx create mode 100644 app/src/components/launchpad/BoostPanel.tsx create mode 100644 app/src/components/launchpad/BoostRulesEntry.tsx create mode 100644 app/src/components/launchpad/BoostedLane.tsx create mode 100644 app/src/components/launchpad/boost-ui.test.ts create mode 100644 app/src/components/launchpad/useBoostOn.ts create mode 100644 app/src/lib/launchpad/boost.test.ts create mode 100644 app/src/lib/launchpad/boost.ts create mode 100644 app/src/lib/launchpad/boostServer.ts create mode 100644 docs/boost.md diff --git a/app/.env.example b/app/.env.example index 8b82b5fa..5c2f0c4b 100644 --- a/app/.env.example +++ b/app/.env.example @@ -52,6 +52,9 @@ IMAGE_STORE=local # Earn (deposit idle USDC into the reviewed Morpho vault from /me, docs/earn.md) is OFF unless this is "on". On Fly: # `fly secrets set EARN=on` restarts the app, no deploy; any other value (or unsetting it) switches it off again. # EARN=on +# Boost (TWIG sent to 0x…dEaD for a place in the Boosted lane, docs/boost.md) is OFF unless this is "on". On Fly: +# `fly secrets set BOOST=on` restarts the app, no deploy; any other value (or unsetting it) switches it off again. +# BOOST=on # Wallets allowed to moderate posts (comma-separated, lowercase) ADMIN_WALLETS= diff --git a/app/db/concurrent-indexes.sql b/app/db/concurrent-indexes.sql index 5c39f688..e02ee3a2 100644 --- a/app/db/concurrent-indexes.sql +++ b/app/db/concurrent-indexes.sql @@ -15,3 +15,5 @@ CREATE INDEX CONCURRENTLY IF NOT EXISTS bb_launch_swaps_trader_idx ON bb_launch_ -- to the season's last block; and transfers out of a launcher or a fee recipient (who got the token from them) CREATE INDEX CONCURRENTLY IF NOT EXISTS bb_token_transfers_to_idx ON bb_token_transfers (chain_id, token, to_addr, block_number); CREATE INDEX CONCURRENTLY IF NOT EXISTS bb_token_transfers_from_idx ON bb_token_transfers (chain_id, token, from_addr, block_number); +-- boosts (2026-10-15): a boost names its launch by position id; the lane joins on it +CREATE INDEX CONCURRENTLY IF NOT EXISTS bb_launches_chain_token_id_idx ON bb_launches (chain_id, token_id); diff --git a/app/db/schema.sql b/app/db/schema.sql index 9a3e5448..cb4814bb 100644 --- a/app/db/schema.sql +++ b/app/db/schema.sql @@ -429,3 +429,22 @@ ALTER TABLE bb_seasons ADD COLUMN IF NOT EXISTS computed_until timestamptz; -- ALTER TABLE bb_seasons ADD COLUMN IF NOT EXISTS published_at timestamptz; -- first publish: the season's clock starts here CREATE INDEX IF NOT EXISTS bb_points_creator_idx ON bb_points (season_id, rank_creator) WHERE rank_creator IS NOT NULL; CREATE INDEX IF NOT EXISTS bb_points_scout_idx ON bb_points (season_id, rank_scout) WHERE rank_scout IS NOT NULL; + +-- ── boosts (TWIG sent to 0x…dEaD for a place in the Boosted lane; lib/launchpad/boost.ts) ────────────────────────── +-- Raw facts from TWIG Transfer logs to 0x…dEaD on Base whose amount names a launch in its last 12 digits: the tag is +-- that launch's bb_launches.token_id (its position id). The launch is joined at read time, so a boost indexed before +-- its launch still counts once the launch is in. Never read by the Live sort, the Trending board or ranking.ts. +CREATE TABLE IF NOT EXISTS bb_boosts ( + chain_id integer NOT NULL, + tx_hash text NOT NULL, + log_index integer NOT NULL, + booster text NOT NULL, -- the Transfer's sender (a smart wallet itself, never its bundler) + amount numeric(78,0) NOT NULL, -- raw TWIG, the tag included + tag bigint NOT NULL, -- amount mod 10^12 + block_number bigint NOT NULL, + block_time timestamptz NOT NULL, + PRIMARY KEY (chain_id, tx_hash, log_index) +); +CREATE INDEX IF NOT EXISTS bb_boosts_time_idx ON bb_boosts (chain_id, block_time DESC); +CREATE INDEX IF NOT EXISTS bb_boosts_tag_idx ON bb_boosts (chain_id, tag); +ALTER TABLE bb_token_settings ADD COLUMN IF NOT EXISTS boost_hidden boolean NOT NULL DEFAULT false; -- admin: kept out of the Boosted lane diff --git a/app/src/app/(home)/page.tsx b/app/src/app/(home)/page.tsx index a9f2737f..cfb3a476 100644 --- a/app/src/app/(home)/page.tsx +++ b/app/src/app/(home)/page.tsx @@ -15,6 +15,8 @@ import { isFilter } from "@/lib/launchpad/search"; import { nowMs } from "@/lib/launchpad/time"; import { feedKey, inRiverWindow, riverCoverage } from "@/lib/launchpad/river"; import TrendingStrip from "@/components/launchpad/TrendingStrip"; +import BoostedLane from "@/components/launchpad/BoostedLane"; +import { boostLaneOrNull } from "@/lib/launchpad/boostServer"; import LaunchPill from "@/components/launchpad/LaunchPill"; import WhyFree from "@/components/launchpad/WhyFree"; import { MagicCard } from "@/components/vendor/magic-card"; @@ -36,12 +38,13 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s const usd = await ethUsd(); const layout = parseLayout((await cookies()).get(LAYOUT_COOKIE)?.value); const listOpts = { sort, window, chain, filter, limit: PAGE_SIZE, ethUsd: usd }; - const [page, feed, fresh, posts, fetchedTrending] = await Promise.all([ + const [page, feed, fresh, posts, fetchedTrending, boost] = await Promise.all([ listLaunchesPage(listOpts), getLaunchFeed(RIVER_SEED, usd), listLaunchesPage({ sort: "new", limit: JUST_LAUNCHED_SIZE, ethUsd: usd }), listFeed(30), isTrendingSource(listOpts) ? null : getTrending(usd), + boostLaneOrNull(usd), ]); const trending = await withBoardTape(fetchedTrending ?? trendingFrom(page.items)); // the default view's first page doubles as the strip's candidates; the tapes are the cards' recent trades const now = nowMs(); @@ -52,6 +55,8 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s {/* what is hot first (names and numbers to act on), then the last half hour as it happens, then every launch */} item.kind === "swap").map(feedKey)} /> + {/* paid and labelled, after the facts-only board and never inside it (boost.ts); nothing while BOOST is off */} + inRiverWindow(item, now))} recent={feed} serverNow={now} coveredSince={riverCoverage(feed, RIVER_SEED, now)} lastActivityAt={feed[0] ? new Date(feed[0].at).getTime() : 0} />
diff --git a/app/src/app/admin/page.tsx b/app/src/app/admin/page.tsx index 20f50eab..a4cd8e85 100644 --- a/app/src/app/admin/page.tsx +++ b/app/src/app/admin/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import AdminQueue from "@/components/launchpad/AdminQueue"; import ProfileQueue from "@/components/profile/ProfileQueue"; import PointsAdmin from "@/components/points/PointsAdmin"; +import BoostAdmin from "@/components/launchpad/BoostAdmin"; export const metadata: Metadata = { title: "Moderation", robots: { index: false, follow: false } }; export const dynamic = "force-dynamic"; @@ -12,11 +13,12 @@ export default function AdminPage() {

Moderation

-

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

+

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

+
); } diff --git a/app/src/app/api/boost/mod/route.ts b/app/src/app/api/boost/mod/route.ts new file mode 100644 index 00000000..f5c41a26 --- /dev/null +++ b/app/src/app/api/boost/mod/route.ts @@ -0,0 +1,16 @@ +import { NextResponse } from "next/server"; +import { rateLimited } from "@/lib/launchpad/editServer"; +import { clientIp, readJson } from "@/lib/profiles/http"; +import { moderateBoost } from "@/lib/launchpad/boostServer"; + +export const dynamic = "force-dynamic"; + +/** POST {action: hide|unhide, token, wallet, nonce, ts, signature}: an admin wallet keeps a Base token out of the Boosted lane, or lets it back. */ +export async function POST(req: Request) { + if (rateLimited(`boost-mod:ip:${clientIp(req)}`, 30)) return NextResponse.json({ error: "slow down" }, { status: 429 }); + const b = await readJson(req); + if (!b) return NextResponse.json({ error: "bad json" }, { status: 400 }); + const r = await moderateBoost({ action: b.action, token: b.token, wallet: b.wallet, nonce: b.nonce, ts: b.ts, signature: b.signature }); + if (!r.ok) return NextResponse.json({ error: r.error }, { status: r.status }); + return NextResponse.json(r); +} diff --git a/app/src/app/api/boost/route.ts b/app/src/app/api/boost/route.ts new file mode 100644 index 00000000..235c2f60 --- /dev/null +++ b/app/src/app/api/boost/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; +import { isAddress } from "viem"; +import { rateLimited } from "@/lib/launchpad/editServer"; +import { clientIp } from "@/lib/profiles/http"; +import { ethUsd } from "@/lib/launchpad/ethPrice"; +import { BOOST_CHAIN } from "@/lib/launchpad/boost"; +import { boostEnabled, getBoostLane, getTokenBoost } from "@/lib/launchpad/boostServer"; + +export const dynamic = "force-dynamic"; + +const NO_STORE = { "cache-control": "no-store" }; + +/** + * GET /api/boost?chain=base → the Boosted lane (`{ enabled, window_h, floor_twig, items }`), or `{ enabled: false }` + * while the BOOST switch is off. GET /api/boost?chain=base&token=0x… → one token's boost state (TWIG in the last 24 + * hours, its place in the lane, whether it is hidden). Boosts exist on Base only. Rate limited per IP. + */ +export async function GET(req: Request) { + if (rateLimited(`boost:ip:${clientIp(req)}`, 60)) return NextResponse.json({ error: "slow down" }, { status: 429 }); + const u = new URL(req.url); + const chain = u.searchParams.get("chain") ?? BOOST_CHAIN; + if (chain !== BOOST_CHAIN) return NextResponse.json({ error: "boosts are on Base only" }, { status: 400 }); + if (!boostEnabled()) return NextResponse.json({ enabled: false }, { headers: NO_STORE }); + const token = u.searchParams.get("token"); + try { + if (token !== null) { + if (!isAddress(token)) return NextResponse.json({ error: "bad token" }, { status: 400 }); + return NextResponse.json(await getTokenBoost(token), { headers: NO_STORE }); + } + return NextResponse.json(await getBoostLane(await ethUsd()), { headers: NO_STORE }); + } catch (err) { + console.error("[boost] read failed:", err instanceof Error ? err.message : err); + return NextResponse.json({ error: "could not load boosts" }, { status: 502 }); + } +} diff --git a/app/src/app/api/launch/live/route.ts b/app/src/app/api/launch/live/route.ts index 6f3b699d..138a1086 100644 --- a/app/src/app/api/launch/live/route.ts +++ b/app/src/app/api/launch/live/route.ts @@ -7,10 +7,11 @@ import { ethUsd } from "@/lib/launchpad/ethPrice"; import { memo } from "@/lib/launchpad/memo"; import { listFeed } from "@/lib/launchpad/postsServer"; import { FEED_POSTS_LIMIT, feedPostsKey } from "@/lib/launchpad/posts-paging"; +import { boostLaneOrNull } from "@/lib/launchpad/boostServer"; export const dynamic = "force-dynamic"; -/** One request for everything live on a page (feed, totals, optional list). Polled every ~5s by LiveProvider. */ +/** One request for everything live on a page (feed, totals, optional list, the Boosted lane). Polled every ~5s by LiveProvider. */ export async function GET(req: Request) { const u = new URL(req.url); const sort = parseSort(u.searchParams.get("sort"), null); @@ -27,15 +28,17 @@ export async function GET(req: Request) { // the home list's first page is the strip's candidate set: rank it instead of running the live query a second time; // any other view fetches the strip in the same batch as everything else const deriveTrending = listOpts !== null && isTrendingSource(listOpts); - const [feed, totals, page, posts, fetchedTrending] = await Promise.all([ + const [feed, totals, page, posts, fetchedTrending, boost] = await Promise.all([ memo("feed", 2_000, () => getLaunchFeed(24, usd)), memo("totals", 2_000, () => getLaunchTotals(usd)), listOpts ? memo(`list:${chain ?? "all"}:${sort}:${window}:${filter ?? "-"}:${limit}`, 2_000, () => listLaunchesPage(listOpts)) : Promise.resolve(null), memo(feedPostsKey(0), 2_000, () => listFeed(FEED_POSTS_LIMIT, 0)), // the same entry /api/posts?feed=1 serves deriveTrending ? Promise.resolve(null) : memo("trending", 2_000, () => getTrending(usd)), + // the switch is read on every poll, so turning BOOST off reaches open tabs within one tick; a failed read sends nothing + boostLaneOrNull(usd), ]); const trending = fetchedTrending ?? trendingFrom(page?.items ?? []); - return NextResponse.json({ at: Date.now(), feed, totals, ethUsd: usd, sort, window, chain, filter, limit, has_more: page?.hasMore ?? null, launches: page?.items ?? null, posts, trending }, { headers: { "cache-control": "no-store" } }); + return NextResponse.json({ at: Date.now(), feed, totals, ethUsd: usd, sort, window, chain, filter, limit, has_more: page?.hasMore ?? null, launches: page?.items ?? null, posts, trending, boost: boost ?? undefined }, { headers: { "cache-control": "no-store" } }); } catch (err) { console.error("[launch] live failed:", err instanceof Error ? err.message : err); return NextResponse.json({ error: "could not load live snapshot" }, { status: 502 }); diff --git a/app/src/app/llms.txt/route.ts b/app/src/app/llms.txt/route.ts index b54d28aa..c04c0f5a 100644 --- a/app/src/app/llms.txt/route.ts +++ b/app/src/app/llms.txt/route.ts @@ -1,6 +1,23 @@ import { SITE_URL } from "@/lib/chainPublic"; import { launchpad } from "@/lib/launchpad/config"; import { BRAND_DOMAIN, BRAND_GITHUB } from "@/lib/brand"; +import { boostEnabled } from "@/lib/launchpad/boostServer"; +import { BOOST_FLOOR_TWIG, BOOST_TOKEN, BOOST_WINDOW_H } from "@/lib/launchpad/boost"; + +/** The Boost section, only while the switch is on (boost.ts holds the rule). */ +function boostSection(): string { + if (!boostEnabled()) return ""; + return ` +## Boost (Base, optional, paid in TWIG) +A separate "Boosted" lane on the home page, ordered by TWIG sent to 0x000000000000000000000000000000000000dEaD in the last ${BOOST_WINDOW_H}h. +It is not part of the ranking: sort=live and the Trending board count outside wallets only. Nothing is paid to openlaunch. +To boost a launch: TWIG (${BOOST_TOKEN}).transfer(0x…dEaD, amount) with amount = whole * 10^18 + token_id +(token_id = the launch's position id, the list API's token_id field; whole >= ${BOOST_FLOOR_TWIG}). The last 12 digits name the launch. +Then POST ${SITE_URL}/api/launch/sync?chain=base&tx=0x… to show it at once. Burns are final. +- GET ${SITE_URL}/api/boost?chain=base (the lane: twig, boosts, last_at, expires_at, launch) +- GET ${SITE_URL}/api/boost?chain=base&token=0x… (one token: twig in the window, place, hidden) +`; +} export const dynamic = "force-dynamic"; @@ -67,6 +84,7 @@ Optional metadata: POST ${SITE_URL}/api/launch/meta {chain, launcher, salt, name Recommended: buy a little right after launch (a Universal Router swap on the new pool; the site's form suggests ≈ $25). A token with no holder and no price move reads as dead on screeners and sits under "quiet launches" on the home page until an outside wallet trades it. +${boostSection()} ## API - GET ${SITE_URL}/api/launch/list?chain=base|robinhood|arc&sort=live|new|mcap|volume|gainers|holders&window=1h|24h|all&limit=50 sort=live (the home page default; "trending" is an alias): tokens an outside wallet traded in the last 24h first, ranked by outside diff --git a/app/src/app/rules/page.tsx b/app/src/app/rules/page.tsx index fad98740..32edcf7a 100644 --- a/app/src/app/rules/page.tsx +++ b/app/src/app/rules/page.tsx @@ -10,6 +10,8 @@ import { CHAINS, CHAIN_KEYS, CHAIN_LABELS, explorerAddress } from "@/lib/chainPu import { BRAND_GITHUB } from "@/lib/brand"; import LaunchMachine from "@/components/launchpad/LaunchMachine"; import { ChainLogo } from "@/components/launchpad/ChainLogo"; +import BoostRulesEntry from "@/components/launchpad/BoostRulesEntry"; +import { boostEnabled } from "@/lib/launchpad/boostServer"; import { RULES } from "@/lib/points/score"; export const metadata: Metadata = pageMetadata({ path: "/rules", title: "How it works", description: "What a launch does on-chain, what it costs (gas), and what can never happen to your liquidity." }); @@ -131,6 +133,7 @@ export default function RulesPage() {
How does the price move?

Price follows a single-sided Uniswap v4 curve: the first buyers get the most tokens per unit of the quote asset, and every buy moves the price up. Sells move it down.

There is no anti-snipe mechanism. Bots can buy in the first block like anyone else.

The launch form suggests a small first buy (about $25) so your token opens with a holder and a price; clear it and the launch stays free.

Why is my token under “quiet launches”?

The home page ranks by facts, not by launch order. A token is live once a wallet other than its launcher has traded it in the last 24 hours; trades in the launch block and the next three blocks (snipe bots) don't count, and neither do the launcher's own. Live tokens rank by outside wallets in the last hour, then today, then by their last outside trade. A wallet's newest launch gets its first hour on the board regardless; after that it sits under quiet launches, one row per wallet, until someone trades it.

Nothing is hidden: every launch stays in the New tab, in search and in the API. The only way up is a trade from an outside wallet.

+ {boostEnabled() ? : null}
What can change after launch?

Nothing on-chain can be edited after launch: not the name, not the fee, not the beneficiaries. Metadata (image, description, links) is stored by this site and can change; the token itself cannot.

What is a TWIG- or GITLAWB-quoted pool?

TWIG is Twigpine's token on Base: a 1:1 wrapper of GITLAWB. Anyone can wrap GITLAWB into TWIG, or unwrap it back, at any time with no fee, and every TWIG is backed by one GITLAWB held in its contract, which has no owner and no transfer restrictions. It is the quote the Base form offers: pick it and buyers pay in TWIG; any trading fee is paid in TWIG to the beneficiaries named at launch (by their shares) when it is collected, and a launch that names none has its TWIG fees burned when they are collected.

GITLAWB is Twigpine's token on Base, bridged 1:1 to Robinhood Chain over LayerZero (one supply, two chains): an ordinary ERC-20 with no transfer restrictions and no issuer switch. The Robinhood Chain form offers it; on Base, pools already paired with GITLAWB keep their badge and price. Fees work the same way: paid in GITLAWB when collected, and a launch that names no beneficiary has its GITLAWB fees burned when they are collected.

Dollar figures for both come from the Uniswap v4 WETH/GITLAWB pool price on Base, cross-checked against a 30-minute on-chain average, and ETH/USD; one TWIG counts as one GITLAWB, since it unwraps to one. They are for display and sorting only; nothing on-chain depends on them.

What should I know about stock-quoted pools?

Stock-quoted pools use tokenized stocks as the quote asset: Coinbase tokenized stocks (B20) on Base, recognised here only from Coinbase's official list and priced in dollars where Chainlink publishes an on-chain feed (the others show no dollar price); Robinhood Stock Tokens on Robinhood Chain, recognised only from Robinhood's own registry.

Both are securities issued by third parties under Regulation S and are not offered to US persons. Coinbase's also exclude the UK, Canada, Australia, Singapore and Switzerland. Their issuers can pause transfers or freeze wallets in restricted jurisdictions. Holding or trading them is subject to the issuer's terms, not ours.

diff --git a/app/src/app/t/[chain]/[token]/page.tsx b/app/src/app/t/[chain]/[token]/page.tsx index 8df1728c..d40eebba 100644 --- a/app/src/app/t/[chain]/[token]/page.tsx +++ b/app/src/app/t/[chain]/[token]/page.tsx @@ -50,6 +50,11 @@ import WatchButton from "@/components/launchpad/WatchButton"; import { BorderBeam } from "@/components/vendor/border-beam"; import { Banner } from "@/components/launchpad/LaunchCard"; import { ChainLogo } from "@/components/launchpad/ChainLogo"; +import BoostPanel from "@/components/launchpad/BoostPanel"; +import { boostEnabled, getTokenBoost, type TokenBoost } from "@/lib/launchpad/boostServer"; +import { BOOST_CHAIN } from "@/lib/launchpad/boost"; +import { gitlawbUsd } from "@/lib/launchpad/gitlawbServer"; +import { twigUsdFromGitlawb } from "@/lib/launchpad/twig"; import type { ProofKey } from "@/lib/launchpad/proof"; /** Where GITLAWB lives, as said beside a GITLAWB-quoted pool. */ @@ -76,7 +81,7 @@ export async function generateMetadata({ params }: { params: Promise<{ chain: st } /** A token's page: market, chart, trades, holders and comments, with every wallet named by its profile where it has one. */ -export default async function TokenPage({ params }: { params: Promise<{ chain: string; token: string }> }) { +export default async function TokenPage({ params, searchParams }: { params: Promise<{ chain: string; token: string }>; searchParams: Promise<{ boost?: string }> }) { const { chain, token } = await params; if (!isChainKey(chain) || !isAddress(token)) notFound(); const usd = await ethUsd(); @@ -93,7 +98,16 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s }; const unlisted = quote.key === "other"; const stockQuote = quote.key === "stock" ? stockByAddress(chain, l.quote) : null; - const [swaps, holders, tint] = await Promise.all([getSwaps(chain, l.token, quote.decimals, 40), memo(`holders:${chain}:${l.token}`, 5_000, () => getHolderPanel(chain, l.token)), tokenTint(l.image_url, l.token)]); + // Boost (Base only, while the switch is on): the token's line and TWIG's price for the dialog; a failed read shows no panel + const boostable = chain === BOOST_CHAIN && boostEnabled(); + const [swaps, holders, tint, boost, twigUsd] = await Promise.all([ + getSwaps(chain, l.token, quote.decimals, 40), + memo(`holders:${chain}:${l.token}`, 5_000, () => getHolderPanel(chain, l.token)), + tokenTint(l.image_url, l.token), + boostable ? getTokenBoost(l.token).catch((): TokenBoost | null => null) : null, + boostable ? gitlawbUsd().then(twigUsdFromGitlawb).catch(() => null) : null, + ]); + const boostOpen = (await searchParams).boost === "1"; // names for every wallet the server renders (trades, creator, fee recipients, top holders): the first paint shows them const nameWallets = [l.launcher, ...swaps.map((s) => s.trader), ...l.recipients.map((r) => r.payout), ...(holders?.top ?? []).map((h) => h.address)].filter((w): w is string => Boolean(w)); // a failed lookup seeds nothing (the browser then asks itself); only an answer says "no profile" for a wallet @@ -238,6 +252,8 @@ export default async function TokenPage({ params }: { params: Promise<{ chain: s
{/* the token at a glance beside the trade box, as other launchpads keep it; the full record is in the About tab */} + {/* Boost (Base, while the switch is on): under the About card, which stays right under the trade box */} + {boost ? : null} diff --git a/app/src/components/CommandPalette.tsx b/app/src/components/CommandPalette.tsx index 4d08d0a4..02768f65 100644 --- a/app/src/components/CommandPalette.tsx +++ b/app/src/components/CommandPalette.tsx @@ -13,6 +13,7 @@ import { setPendingToken } from "@/lib/launchpad/token-transition"; import { buildPaletteGroups, isSearchableQuery, paletteTokenFrom, type PaletteGroup, type PaletteItem, type PaletteToken } from "@/lib/command-palette"; import { useOpenBridge } from "./bridge/BridgeProvider"; import { useEarnInfo } from "./earn/earnInfo"; +import { useBoostOn } from "./launchpad/useBoostOn"; type LaunchesResponse = { launches?: Parameters[0][] }; @@ -67,7 +68,9 @@ export default function CommandPalette({ open, onOpenChange }: { open: boolean; const theme = resolvedTheme === "dark" ? "dark" : "light"; // the Earn entry exists only while the feature is switched on; asked once, on the first open const earn = useEarnInfo(open)?.enabled === true; - const groups = useMemo(() => buildPaletteGroups({ query, tokens: current?.tokens ?? [], trending: trending ?? [], theme, earn }), [query, current, trending, theme, earn]); + // the Boosted entry follows the live poll's switch: no request of its own + const boost = useBoostOn(); + const groups = useMemo(() => buildPaletteGroups({ query, tokens: current?.tokens ?? [], trending: trending ?? [], theme, earn, boost }), [query, current, trending, theme, earn, boost]); const status = searching ? "Searching tokens…" : current?.failed ? "Token search is unavailable right now. Pages and actions still work." : ""; const close = () => { diff --git a/app/src/components/launchpad/BoostAdmin.tsx b/app/src/components/launchpad/BoostAdmin.tsx new file mode 100644 index 00000000..b5398ed4 --- /dev/null +++ b/app/src/components/launchpad/BoostAdmin.tsx @@ -0,0 +1,82 @@ +"use client"; + +import Link from "next/link"; +import { useCallback, useEffect, useState } from "react"; +import { useConfig } from "wagmi"; +import { getWalletClient } from "wagmi/actions"; +import { isAddress } from "viem"; +import { useHydratedAccount } from "@/lib/useHydratedAccount"; +import { btn, card } from "@/components/ui"; +import type { BoostLane } from "@/lib/launchpad/boostServer"; +import { BOOST_CHAIN, buildBoostModMessage, fmtBoostTwig, type BoostModAction } from "@/lib/launchpad/boost"; +import { CHAINS } from "@/lib/chainPublic"; +import { nowMs } from "@/lib/launchpad/time"; +import { friendlyError } from "@/lib/errors"; +import { nonce } from "@/lib/nonce"; + +/** + * The Boosted lane for moderators: what is in it now, Hide on each card, and Hide / Unhide by address (a hidden token is + * no longer listed anywhere, so showing it again takes its address). Each action is an admin-wallet signature. Hiding + * never refunds: the TWIG was burned on-chain. + */ +export default function BoostAdmin() { + const { address } = useHydratedAccount(); + const config = useConfig(); + const [lane, setLane] = useState(null); + const [target, setTarget] = useState(""); + const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null); + const load = useCallback(async () => { + const res = await fetch(`/api/boost?chain=${BOOST_CHAIN}`, { cache: "no-store" }).catch(() => null); + if (res?.ok) setLane((await res.json()) as BoostLane); + }, []); + useEffect(() => { + const id = setTimeout(() => void load(), 0); + return () => clearTimeout(id); + }, [load]); + + async function act(token: string, action: BoostModAction) { + if (!address) return; + try { + const n = nonce(); + const ts = nowMs(); + const wallet = await getWalletClient(config, { chainId: CHAINS[BOOST_CHAIN].id }); + const signature = await wallet.signMessage({ message: buildBoostModMessage({ action, token, wallet: address, nonce: n, ts }) }); + const res = await fetch("/api/boost/mod", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action, token, wallet: address, nonce: n, ts, signature }) }); + const d = (await res.json()) as { error?: string }; + if (!res.ok) throw new Error(d.error ?? "failed"); + setMsg({ ok: true, text: `${action === "hide" ? "Hidden" : "Shown again"}: ${token}` }); + await load(); + } catch (e) { + setMsg({ ok: false, text: friendlyError(e) }); + } + } + + if (lane && !lane.enabled) return null; + return ( +
+

Boosted lane

+ {!address ?

Connect the admin wallet.

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

Nothing is boosted right now.

: null} + {lane?.enabled + ? lane.items.map((b) => ( +
+
+ {b.launch.name} + {fmtBoostTwig(b.twig)} · {b.boosts} {b.boosts === 1 ? "boost" : "boosts"} +
+ +
+ )) + : null} +
+ +
+ setTarget(e.target.value.trim())} placeholder="0x…" className="min-h-9 min-w-0 flex-1 rounded-lg border border-line bg-paper px-3 font-mono text-xs text-ink" /> + + +
+
+ {msg ?

{msg.text}

: null} +
+ ); +} diff --git a/app/src/components/launchpad/BoostDialog.tsx b/app/src/components/launchpad/BoostDialog.tsx new file mode 100644 index 00000000..737216c2 --- /dev/null +++ b/app/src/components/launchpad/BoostDialog.tsx @@ -0,0 +1,222 @@ +"use client"; + +import Link from "next/link"; +import { useMemo, useRef, useState } from "react"; +import { useConfig, useReadContract, useSwitchChain } from "wagmi"; +import { getPublicClient, getWalletClient } from "wagmi/actions"; +import type { Hex } from "viem"; +import { ArrowUpRight } from "lucide-react"; +import Sheet from "@/components/Sheet"; +import ConnectWallet from "@/components/ConnectWallet"; +import { btn } from "@/components/ui"; +import { Spinner } from "@/components/Skeleton"; +import { useHydratedAccount } from "@/lib/useHydratedAccount"; +import { ERC20_MIN_ABI, ERC20_TRANSFER_ABI } from "@/lib/launchpad/abi"; +import { DEAD } from "@/lib/launchpad/config"; +import { TWIG_WRAP_URL } from "@/lib/launchpad/twig"; +import { fmtUsd } from "@/lib/launchpad/math"; +import { friendlyError } from "@/lib/errors"; +import { BUILDER_DATA_SUFFIX, CHAINS, explorerTx } from "@/lib/chainPublic"; +import { + BOOST_CHAIN, + BOOST_COPY, + BOOST_FLOOR_TWIG, + BOOST_PRESETS, + BOOST_TOKEN, + boostAmount, + boostAmountProblem, + boostConfirmLabel, + fmtBoostTwig, + groupWhole, + isLaunchTag, + maxBoostWhole, + parseBoostWhole, +} from "@/lib/launchpad/boost"; +import { toast } from "./TxToasts"; +import { TwigMark } from "./TwigBadge"; + +/** A boost the wallet has sent whose outcome is not known yet; the panel keeps it while the dialog is closed. */ +export type PendingBoost = { hash: Hex; whole: bigint }; +type Phase = + | { k: "idle" } + | { k: "preparing" } + | { k: "signing" } + | { k: "sent"; hash: Hex; whole: bigint } + | { k: "unconfirmed"; hash: Hex; whole: bigint; checking: boolean; note: string | null } + | { k: "done"; hash: Hex; whole: bigint } + | { k: "error"; message: string }; + +const BASE = CHAINS[BOOST_CHAIN]; + +/** + * Send TWIG to 0x…dEaD for a token's place in the Boosted lane (boost.ts): one plain TWIG.transfer whose amount ends in + * the launch's position id. No approval, nothing to sign but the transfer. Whole TWIG only; the floor and the balance + * (tag included) are checked before the button wakes, and the transfer is simulated before the wallet is asked. + * Sent means sent: once the wallet returns a hash, only a receipt ends the action. A receipt that cannot be read leaves + * it unconfirmed and locked, with "Check again", so a slow node never invites a second burn; the panel holds the hash + * (`pending`) so closing and reopening the dialog keeps the lock. + */ +export default function BoostDialog({ token, tokenId, symbol, name, twigUsd, pending, onPending, onClose, onBoosted }: { token: string; tokenId: number; symbol: string; name: string; twigUsd: number | null; pending: PendingBoost | null; onPending: (p: PendingBoost | null) => void; onClose: () => void; onBoosted?: () => void }) { + const config = useConfig(); + const { address, isConnected, chainId } = useHydratedAccount(); + const { switchChainAsync } = useSwitchChain(); + const [amount, setAmount] = useState(groupWhole(BOOST_FLOOR_TWIG)); + const [phase, setPhase] = useState(pending ? { k: "unconfirmed", ...pending, checking: false, note: null } : { k: "idle" }); + const lock = useRef(false); + const tag = BigInt(tokenId); + const tagOk = isLaunchTag(tag); + + const bal = useReadContract({ address: BOOST_TOKEN as `0x${string}`, abi: ERC20_MIN_ABI, functionName: "balanceOf", args: address ? [address] : undefined, chainId: BASE.id, query: { enabled: Boolean(address), refetchInterval: 15_000 } }); + const balance = bal.data as bigint | undefined; + const whole = parseBoostWhole(amount); + const problem = tagOk ? boostAmountProblem(whole, balance, tag) : "This launch cannot be boosted."; + const raw = useMemo(() => (whole !== null && problem === null ? boostAmount(whole, tag) : null), [whole, problem, tag]); + const max = balance !== undefined && tagOk ? maxBoostWhole(balance, tag) : null; + const busy = phase.k === "preparing" || phase.k === "signing" || phase.k === "sent" || phase.k === "unconfirmed"; + const usd = whole !== null && twigUsd ? fmtUsd(Number(whole) * twigUsd) : null; + + async function send() { + if (!address || raw === null || whole === null || busy || lock.current) return; + lock.current = true; // before the first await + setPhase({ k: "preparing" }); + try { + if (chainId !== BASE.id) await switchChainAsync({ chainId: BASE.id }); + const pub = getPublicClient(config, { chainId: BASE.id })!; + const wallet = await getWalletClient(config, { chainId: BASE.id }); + const { request } = await pub.simulateContract({ address: BOOST_TOKEN as `0x${string}`, abi: ERC20_TRANSFER_ABI, functionName: "transfer", args: [DEAD, raw], account: address, dataSuffix: BUILDER_DATA_SUFFIX }); + setPhase({ k: "signing" }); + const hash = await wallet.writeContract(request); + onPending({ hash, whole }); + setPhase({ k: "sent", hash, whole }); + let rc; + try { + rc = await pub.waitForTransactionReceipt({ hash }); + } catch { + // sent, outcome unknown: never an error that would let the same TWIG be sent twice + setPhase({ k: "unconfirmed", hash, whole, checking: false, note: null }); + return; + } + await settle(hash, whole, rc.status === "success"); + } catch (err) { + setPhase({ k: "error", message: friendlyError(err) }); + } finally { + lock.current = false; + } + } + + /** A final answer for a sent boost: indexed and done, or reverted (sending again is then safe). */ + async function settle(hash: Hex, whole: bigint, ok: boolean) { + onPending(null); + if (!ok) { + setPhase({ k: "error", message: "The transfer reverted on-chain. No TWIG was sent; you can try again." }); + return; + } + // index it now rather than at the next poll: the lane and this page show it straight away + await fetch(`/api/launch/sync?chain=${BOOST_CHAIN}&tx=${hash}`, { method: "POST" }).catch(() => {}); + setPhase({ k: "done", hash, whole }); + toast({ kind: "info", title: `${symbol} is boosted`, sub: `${groupWhole(whole)} TWIG sent to 0x…dEaD`, chain: BOOST_CHAIN, token, symbol }); + void bal.refetch(); + onBoosted?.(); + } + + async function checkAgain() { + if (phase.k !== "unconfirmed" || phase.checking) return; + const { hash, whole } = phase; + setPhase({ ...phase, checking: true, note: null }); + try { + const rc = await getPublicClient(config, { chainId: BASE.id })!.getTransactionReceipt({ hash }); + await settle(hash, whole, rc.status === "success"); + } catch { + setPhase({ k: "unconfirmed", hash, whole, checking: false, note: "Not confirmed yet. Check your wallet's activity before sending anything again." }); + } + } + + return ( + + {phase.k === "done" ? ( +
+

{name} is in the Boosted lane for the next 24 hours.

+

{fmtBoostTwig(boostAmount(phase.whole, tag))} went to 0x…dEaD. More boosts from anyone in that time add up; the lane orders by the total.

+
+ View transaction + See the lane + +
+
+ ) : ( +
+

{BOOST_COPY.what}

+ +
+
+ + {balance !== undefined ? ( + + ) : null} +
+
+ setAmount(e.target.value.replace(/[^\d,]/g, "").slice(0, 16))} + className="w-full min-w-0 bg-transparent py-1 font-mono text-[28px] leading-tight text-ink placeholder:text-faint focus-visible:outline-none! tnum" + placeholder={groupWhole(BOOST_FLOOR_TWIG)} + aria-describedby={`boost-${token}-note`} + /> + TWIG +
+
+ {BOOST_PRESETS.map((p) => ( + + ))} +
+

{problem ?? (usd ? `≈ ${usd} at today's TWIG price` : " ")}

+
+ +
+
Goes to
0x…dEaD (burned)
+
Lasts
24 hours from this boost
+
Platform fee
$0
+
Amount ends in
…{tokenId}
+
+

{BOOST_COPY.tag}

+

{BOOST_COPY.final}

+ + {balance === 0n ? ( +

+ No TWIG in this wallet. Wrap GITLAWB 1:1 or buy TWIG at{" "} + wrap.twigpine.com ↗ +

+ ) : null} + + {!isConnected ? ( + Connect a wallet to boost + ) : ( + + )} + {phase.k === "sent" ? View the transaction ↗ : null} + {phase.k === "unconfirmed" ? ( +
+

Your wallet sent {groupWhole(phase.whole)} TWIG, but Base has not confirmed it to this page yet. Sending is locked until it does, so the same boost is never paid twice.

+ {phase.note ?

{phase.note}

: null} +
+ + View the transaction ↗ +
+
+ ) : null} + {phase.k === "error" ?

{phase.message}

: null} +
+ )} +
+ ); +} diff --git a/app/src/components/launchpad/BoostPanel.tsx b/app/src/components/launchpad/BoostPanel.tsx new file mode 100644 index 00000000..c1865658 --- /dev/null +++ b/app/src/components/launchpad/BoostPanel.tsx @@ -0,0 +1,69 @@ +"use client"; + +import dynamic from "next/dynamic"; +import { useCallback, useEffect, useState } from "react"; +import { ArrowUpRight } from "lucide-react"; +import { btn } from "@/components/ui"; +import { useLive } from "./LiveProvider"; +import { TwigMark } from "./TwigBadge"; +import type { TokenBoost } from "@/lib/launchpad/boostServer"; +import { BOOST_CHAIN, BOOST_COPY, boostTimeLeft, fmtBoostTwig } from "@/lib/launchpad/boost"; +import { explorerTx } from "@/lib/chainPublic"; +import type { PendingBoost } from "./BoostDialog"; + +// the dialog (wallet reads, the send flow) loads on first use, not with the page +const BoostDialog = dynamic(() => import("./BoostDialog"), { ssr: false }); + +/** + * A Base token's boost line on its page: whether it is in the Boosted lane, with how much TWIG and for how long, and + * the button that opens the dialog. Nothing while BOOST is off. A token an admin hid says so and offers no button, so + * nobody sends TWIG for a place it cannot get. `autoOpen` comes from /me's "Boost" link (?boost=1). + */ +export default function BoostPanel({ token, tokenId, symbol, name, initial, twigUsd, serverNow, autoOpen = false }: { token: string; tokenId: number; symbol: string; name: string; initial: TokenBoost; twigUsd: number | null; serverNow: number; autoOpen?: boolean }) { + const { refresh } = useLive(); + const [state, setState] = useState(initial); + const [open, setOpen] = useState(autoOpen && initial.enabled && !initial.hidden); + const [now, setNow] = useState(serverNow); + // a sent boost whose receipt is not read yet: kept here so a closed and reopened dialog stays locked on it + const [pending, setPending] = useState(null); + useEffect(() => { + const clock = setInterval(() => setNow(Date.now()), 30_000); + return () => clearInterval(clock); + }, []); + const reload = useCallback(() => { + fetch(`/api/boost?chain=${BOOST_CHAIN}&token=${token}`, { cache: "no-store" }) + .then(async (r) => (r.ok ? setState((await r.json()) as TokenBoost) : undefined)) + .catch(() => {}); + refresh(); // the lane on other cards and the hero's TWIG burned + }, [token, refresh]); + + if (!state.enabled) return null; + const left = state.last_at ? boostTimeLeft(state.last_at, now) : null; + const active = left !== null && BigInt(state.twig) > 0n; + return ( +
+
+

Boost

+ {active && state.place ? #{state.place} in the lane : null} +
+ {state.hidden ? ( +

{BOOST_COPY.hidden}

+ ) : ( + <> +

+ {active ? ( + <>Boosted with {fmtBoostTwig(state.twig)} in the last 24 hours · {left}. + ) : ( + <>Not boosted. Sending TWIG to 0x…dEaD puts {symbol} in the Boosted lane for 24 hours. + )} +

+
+ + {active && state.last_tx ? Latest burn : null} +
+ + )} + {open ? setOpen(false)} onBoosted={reload} /> : null} +
+ ); +} diff --git a/app/src/components/launchpad/BoostRulesEntry.tsx b/app/src/components/launchpad/BoostRulesEntry.tsx new file mode 100644 index 00000000..44ee48f0 --- /dev/null +++ b/app/src/components/launchpad/BoostRulesEntry.tsx @@ -0,0 +1,16 @@ +import { ChevronDown } from "lucide-react"; +import { BOOST_COPY } from "@/lib/launchpad/boost"; + +/** The rules page's Boost question; the page renders it only while the switch is on (boostEnabled, read per request). */ +export default function BoostRulesEntry() { + return ( +
+ What is the Boosted lane? +
+

It is a separate, labelled row on the home page and the Base page. A place in it is paid for by sending TWIG to 0x…dEaD: the TWIG is gone, and nothing is paid to openlaunch or Twigpine. It is not part of the ranking above: the Live sort and the Trending board count outside wallets only, and no boost changes them.

+

{BOOST_COPY.what} {BOOST_COPY.how}

+

{BOOST_COPY.tag} {BOOST_COPY.final}

+
+
+ ); +} diff --git a/app/src/components/launchpad/BoostedLane.tsx b/app/src/components/launchpad/BoostedLane.tsx new file mode 100644 index 00000000..8827adcf --- /dev/null +++ b/app/src/components/launchpad/BoostedLane.tsx @@ -0,0 +1,97 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { ArrowUpRight, ChevronDown } from "lucide-react"; +import TokenAvatar from "./TokenAvatar"; +import { TwigMark } from "./TwigBadge"; +import { useLive } from "./LiveProvider"; +import type { BoostLane } from "@/lib/launchpad/boostServer"; +import { BOOST_COPY, BOOST_LANE_SIZE, boostTimeLeft, fmtBoostTwig } from "@/lib/launchpad/boost"; +import { capDisplay } from "@/lib/launchpad/market-cap"; +import { explorerTx } from "@/lib/chainPublic"; +import { setPendingToken } from "@/lib/launchpad/token-transition"; + +/** + * The Boosted lane: tokens someone sent TWIG to 0x…dEaD for, most TWIG in the last 24 hours first (boost.ts). A paid, + * labelled surface of its own, never mixed into the Live sort or the Trending board. Renders nothing while the BOOST + * switch is off; the shared live poll carries the lane, and a poll from a machine that predates it keeps what is shown. + */ +export default function BoostedLane({ initial, serverNow }: { initial: BoostLane; serverNow: number }) { + const { subscribe } = useLive(); + const [lane, setLane] = useState(initial); + const [now, setNow] = useState(serverNow); + useEffect(() => { + const unsubscribe = subscribe((snap) => { + if (snap.boost) setLane(snap.boost); + }); + const clock = setInterval(() => setNow(Date.now()), 30_000); + return () => { + unsubscribe(); + clearInterval(clock); + }; + }, [subscribe]); + + if (!lane.enabled) return null; + // a boost that ran out between polls leaves at once rather than showing "0m left" + const items = lane.items.filter((b) => boostTimeLeft(b.last_at, now) !== null); + return ( +
+
+

+ + {BOOST_COPY.laneTitle} +

+

{BOOST_COPY.laneNote}

+
+ {/* the whole rule, in place: no page to keep in step with the switch */} +
+ How boosts work +
+

{BOOST_COPY.what}

+

{BOOST_COPY.how} {BOOST_COPY.tag}

+

{BOOST_COPY.final}

+
+
+ {items.length === 0 ? ( +

{BOOST_COPY.laneEmpty}

+ ) : ( +
    + {items.map((b, i) => { + const l = b.launch; + // 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; + 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); + return ( +
  1. + setPendingToken({ chain: l.chain, token: l.token, name: l.name, symbol: l.symbol, image: l.image_url })} + className="group flex min-w-0 items-center gap-2.5" + > + + + {l.name} + {l.symbol}, market cap {cap} + + +

    + {fmtBoostTwig(b.twig)} + {left} +

    + + Latest burn +
  2. + ); + })} + {/* 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) => ( + + ))} +
+ )} +
+ ); +} diff --git a/app/src/components/launchpad/ChainLanding.tsx b/app/src/components/launchpad/ChainLanding.tsx index a7da7a0d..2690b199 100644 --- a/app/src/components/launchpad/ChainLanding.tsx +++ b/app/src/components/launchpad/ChainLanding.tsx @@ -4,6 +4,9 @@ import Link from "next/link"; import { notFound } from "next/navigation"; import { ArrowRight, ArrowUpRight } from "lucide-react"; import LaunchList from "@/components/launchpad/LaunchList"; +import BoostedLane from "@/components/launchpad/BoostedLane"; +import { boostLaneOrNull } from "@/lib/launchpad/boostServer"; +import { BOOST_CHAIN } from "@/lib/launchpad/boost"; import SectionIntro from "@/components/sections/SectionIntro"; import shell from "@/components/sections/SectionShell.module.css"; import { CHAIN_KEYS, CHAIN_LABELS, CHAINS, EXPLORERS, explorerAddress, type ChainKey } from "@/lib/chainPublic"; @@ -33,9 +36,10 @@ export default async function ChainLanding({ chain }: { chain: ChainKey }) { const c = CHAIN_LANDING[chain]; const lp = launchpad(chain); const usd = await ethUsd(); - const [page, totals] = await Promise.all([ + const [page, totals, boost] = await Promise.all([ listLaunchesPage({ sort: "live", window: "all", chain, filter: null, limit: PAGE_SIZE, ethUsd: usd }), getLaunchTotalsForRequest(usd), // the root layout already ran it this request: shared, not repeated + chain === BOOST_CHAIN ? boostLaneOrNull(usd) : null, // boosts exist on Base only ]); const launches = totals.by_chain[chain].launches; const now = nowMs(); @@ -66,6 +70,8 @@ export default async function ChainLanding({ chain }: { chain: ChainKey }) { ) : null} + {boost?.enabled ?
: null} +

Tokens on {CHAIN_LABELS[chain]}

diff --git a/app/src/components/launchpad/LaunchMechanism.tsx b/app/src/components/launchpad/LaunchMechanism.tsx index 52edb0ab..f1f2a0c2 100644 --- a/app/src/components/launchpad/LaunchMechanism.tsx +++ b/app/src/components/launchpad/LaunchMechanism.tsx @@ -45,6 +45,13 @@ export default function LaunchMechanism() { // TWIG burned: its own figure (the GITLAWB behind it stays in the TWIG contract), shown once there is any; null-safe for a poll from an older machine const twigBurned = t.twig_burned ?? "0"; const twigBurnedExact = `${fmtUnitsExact(twigBurned, TWIG_DECIMALS)} ${TWIG_SYMBOL}`; + // boosts send TWIG to 0x…dEaD too: the label says where each part came from once there is any boost (null-safe for an older machine) + const twigBoosted = BigInt(t.twig_boost_burned ?? "0"); + // never below zero: mid rolling deploy the two figures can come from machines a release apart + const twigFees = BigInt(twigBurned) > twigBoosted ? BigInt(twigBurned) - twigBoosted : 0n; + const twigBurnedWhy = twigBoosted > 0n + ? `Sent to 0x…dEaD on Base: ${fmtQuote(twigFees.toString(), TWIG_DECIMALS, TWIG_SYMBOL)} from the trading fees of TWIG-quoted launches, ${fmtQuote(twigBoosted.toString(), TWIG_DECIMALS, TWIG_SYMBOL)} by boosts` + : "Sent to 0x…dEaD by TWIG-quoted launches on Base"; return
{count(t.trades)} @@ -63,7 +70,7 @@ export default function LaunchMechanism() {
Fees to recipients
{usd(t.fees_to_creators_usd)}
Fees burned
{usd(t.fees_burned_usd)}
GITLAWB burned
{fmtQuote(t.gitlawb_burned, GITLAWB_DECIMALS, GITLAWB_SYMBOL)}
- {BigInt(twigBurned) > 0n ?
TWIG burned
{fmtQuote(twigBurned, TWIG_DECIMALS, TWIG_SYMBOL)}
: null} + {BigInt(twigBurned) > 0n ?
TWIG burned
{fmtQuote(twigBurned, TWIG_DECIMALS, TWIG_SYMBOL)}
: null}
{/* the "≈" explained in the open: a title alone cannot be reached by touch or keyboard */} {usdNote ?

≈ {usdNote}

: null} diff --git a/app/src/components/launchpad/LiveProvider.tsx b/app/src/components/launchpad/LiveProvider.tsx index 2da444c0..51c04d83 100644 --- a/app/src/components/launchpad/LiveProvider.tsx +++ b/app/src/components/launchpad/LiveProvider.tsx @@ -5,6 +5,7 @@ import type { FeedItem, LaunchRow, LaunchSort, LaunchTotals, TrendingSnap, Volum import type { ChainKey } from "@/lib/chainPublic"; import type { LaunchFilter } from "@/lib/launchpad/search"; import type { PostRow } from "@/lib/launchpad/postsServer"; +import type { BoostLane } from "@/lib/launchpad/boostServer"; /** * One poller for the whole page. Everything live (toasts, tape, hero totals, @@ -26,6 +27,8 @@ export type Live = { has_more?: boolean | null; posts?: PostRow[]; trending?: TrendingSnap; + /** The Boosted lane; absent from a machine that predates it (the lane then keeps what it had). */ + boost?: BoostLane; }; type ListParams = { sort: LaunchSort; window: VolumeWindow; chain: ChainKey | null; filter: LaunchFilter | null; limit: number }; type Listener = (live: Live) => void; diff --git a/app/src/components/launchpad/MeDashboard.tsx b/app/src/components/launchpad/MeDashboard.tsx index 1ef62aa5..617bcc97 100644 --- a/app/src/components/launchpad/MeDashboard.tsx +++ b/app/src/components/launchpad/MeDashboard.tsx @@ -2,7 +2,7 @@ import Link from "next/link"; import { useCallback, useEffect, useState } from "react"; -import { ArrowDownLeft, ArrowRight, ArrowUpRight, Coins, Hourglass, Layers3, LockKeyhole, PencilLine, RefreshCw, Wallet } from "lucide-react"; +import { ArrowDownLeft, ArrowRight, ArrowUpRight, Coins, Flame, Hourglass, Layers3, LockKeyhole, PencilLine, RefreshCw, Wallet } from "lucide-react"; import { useConfig } from "wagmi"; import { useHydratedAccount } from "@/lib/useHydratedAccount"; import { getPublicClient, getWalletClient } from "wagmi/actions"; @@ -30,6 +30,8 @@ import ConnectWallet from "@/components/ConnectWallet"; import ProfileIdentity from "@/components/profile/ProfileIdentity"; import SeasonCard from "@/components/points/SeasonCard"; import EarnCard from "@/components/earn/EarnCard"; +import { useBoostOn } from "./useBoostOn"; +import { BOOST_CHAIN } from "@/lib/launchpad/boost"; import styles from "./MeDashboard.module.css"; type Me = { wallet: string; ethUsd: number | null; launches: LaunchRow[]; tokens: (LaunchRow & { my_buys: number; my_sells: number; my_last_trade: string })[]; trades: WalletTrade[] }; @@ -53,6 +55,7 @@ export default function MeDashboard() { /** The dashboard for one connected wallet: its profile card, figures, launches, holdings and trades. */ function WalletDashboard({ address, isConnected }: { address: Address | undefined; isConnected: boolean }) { const config = useConfig(); + const boostOn = useBoostOn(); const [me, setMe] = useState(null); const [err, setErr] = useState(null); const [pending, setPending] = useState({}); @@ -287,6 +290,12 @@ function WalletDashboard({ address, isConnected }: { address: Address | undefine + {/* Boost opens the dialog on the token's page (Base only, while the switch is on) */} + {boostOn && l.chain === BOOST_CHAIN ? ( + +
); diff --git a/app/src/components/launchpad/boost-ui.test.ts b/app/src/components/launchpad/boost-ui.test.ts new file mode 100644 index 00000000..8d2e0349 --- /dev/null +++ b/app/src/components/launchpad/boost-ui.test.ts @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import { readFileSync, readdirSync, statSync } from "node:fs"; +import path from "node:path"; +import test from "node:test"; + +// Source contracts for Boost (like earn-ui.test.ts): they hold the rules that keep a paid lane honest in place. +const read = (p: string) => readFileSync(new URL(p, import.meta.url), "utf8"); +const dialog = read("./BoostDialog.tsx"); +const panel = read("./BoostPanel.tsx"); +const lane = read("./BoostedLane.tsx"); +const admin = read("./BoostAdmin.tsx"); +const rulesEntry = read("./BoostRulesEntry.tsx"); +const rules = read("../../app/rules/page.tsx"); +const dashboard = read("./MeDashboard.tsx"); +const server = read("../../lib/launchpad/boostServer.ts"); +const indexer = read("../../lib/launchpad/indexer.ts"); +const queries = read("../../lib/launchpad/queries.ts"); +const liveRoute = read("../../app/api/launch/live/route.ts"); +const boostRoute = read("../../app/api/boost/route.ts"); +const llms = read("../../app/llms.txt/route.ts"); +const home = read("../../app/(home)/page.tsx"); +const tokenPage = read("../../app/t/[chain]/[token]/page.tsx"); + +test("the ranking never reads boosts: no paid row can enter the Live sort or the Trending board", () => { + // boost code, not the English word (the board's own copy says fresh tokens "get a boost" in score) + const BOOST_CODE = /from ["'][^"']*[bB]oost[^"']*["']|bb_boosts|BOOST_|[bB]oostLane|useBoostOn|getTokenBoost|live\.boost|snap\.boost/; + for (const f of ["../../lib/launchpad/ranking.ts", "../../lib/launchpad/trending-live.ts", "../../lib/launchpad/trending-board.ts", "./TrendingStrip.tsx", "./LaunchList.tsx"]) { + assert.doesNotMatch(read(f), BOOST_CODE, f); + } + // queries.ts names bb_boosts only inside getLaunchTotals (the TWIG burned figure), never in a list or ranking query + const start = queries.indexOf("export async function getLaunchTotals("); + const end = queries.indexOf("\n}\n", start); + assert.ok(start > 0 && end > start); + // the one import it needs is the chain id the totals filter on + const IMPORT = 'import { BOOST_CHAIN_ID } from "./boost";\n'; + assert.equal(queries.split(IMPORT).length, 2, "exactly one boost import, the chain id"); + const outside = (queries.slice(0, start) + queries.slice(end)).replace(IMPORT, ""); + assert.doesNotMatch(outside, BOOST_CODE); + assert.match(queries.slice(start, end), /FROM bb_boosts WHERE chain_id = \$\{BOOST_CHAIN_ID\}/); +}); + +test("a boost is one plain TWIG transfer to 0x…dEaD: simulated, credited to the builder code, never an approval", () => { + assert.match(dialog, /simulateContract\(\{ address: BOOST_TOKEN as `0x\$\{string\}`, abi: ERC20_TRANSFER_ABI, functionName: "transfer", args: \[DEAD, raw\], account: address, dataSuffix: BUILDER_DATA_SUFFIX \}\)/); + assert.equal((dialog.match(/writeContract\(/g) ?? []).length, 1); + assert.match(dialog, /wallet\.writeContract\(request\)/); + assert.doesNotMatch(dialog, /approve|maxUint|sendTransaction|signMessage/); + // the amount is built from the launch's own position id, only once the checks pass + assert.match(dialog, /whole !== null && problem === null \? boostAmount\(whole, tag\) : null/); + assert.match(dialog, /const tag = BigInt\(tokenId\)/); + // the transfer ABI is reachable from the dialog alone + const users = walk(path.resolve(import.meta.dirname, "../..")).filter((f) => !f.endsWith(".test.ts") && readFileSync(f, "utf8").includes("ERC20_TRANSFER_ABI")); + assert.deepEqual(users.map((f) => path.basename(f)).sort(), ["BoostDialog.tsx", "abi.ts"]); +}); + +test("the send locks before its first await and simulates before the wallet is asked", () => { + const body = dialog.slice(dialog.indexOf("async function send()")); + const lockAt = body.indexOf("lock.current = true"); + assert.ok(lockAt > 0 && lockAt < body.indexOf("await"), "lock before the first await"); + assert.ok(body.indexOf("simulateContract") < body.indexOf("writeContract"), "simulate before write"); + assert.match(body, /if \(chainId !== BASE\.id\) await switchChainAsync\(\{ chainId: BASE\.id \}\)/); +}); + +test("sent means sent: an unread receipt locks the dialog instead of inviting a second burn", () => { + const body = dialog.slice(dialog.indexOf("async function send()"), dialog.indexOf("async function settle(")); + // the hash is handed to the panel before the receipt wait, and a failed wait is "unconfirmed", never "error" + assert.ok(body.indexOf("onPending({ hash, whole })") < body.indexOf("waitForTransactionReceipt")); + assert.match(body, /catch \{\n\s*\/\/ sent, outcome unknown[^\n]*\n\s*setPhase\(\{ k: "unconfirmed", hash, whole, checking: false, note: null \}\);\n\s*return;/); + assert.match(dialog, /const busy = phase\.k === "preparing" \|\| phase\.k === "signing" \|\| phase\.k === "sent" \|\| phase\.k === "unconfirmed";/); + assert.match(dialog, /useState\(pending \? \{ k: "unconfirmed", \.\.\.pending, checking: false, note: null \} : \{ k: "idle" \}\)/); + // the panel owns the pending hash, so closing and reopening keeps the lock + assert.match(panel, /pending=\{pending\} onPending=\{setPending\}/); + // only a receipt settles it, and the sync call indexes it at once + assert.match(dialog, /await fetch\(`\/api\/launch\/sync\?chain=\$\{BOOST_CHAIN\}&tx=\$\{hash\}`, \{ method: "POST" \}\)/); +}); + +test("the dialog says where the TWIG goes, that it is final, and that the ranking is untouched", () => { + for (const key of ["what", "tag", "final"]) assert.match(dialog, new RegExp(`BOOST_COPY\\.${key}`), key); + assert.match(dialog, /0x…dEaD \(burned\)/); + assert.match(dialog, /Platform fee<\/dt>]*>\$0/); +}); + +test("everything stays dark while BOOST is off", () => { + assert.match(server, /return process\.env\.BOOST\?\.trim\(\)\.toLowerCase\(\) === "on";/); + assert.match(lane, /if \(!lane\.enabled\) return null;/); + assert.match(panel, /if \(!state\.enabled\) return null;/); + assert.match(admin, /if \(lane && !lane\.enabled\) return null;/); + assert.match(rules, /\{boostEnabled\(\) \? : null\}/); + assert.doesNotMatch(rulesEntry, /"use client"/); + assert.match(dashboard, /boostOn && l\.chain === BOOST_CHAIN \?/); + assert.match(llms, /if \(!boostEnabled\(\)\) return "";/); + assert.match(boostRoute, /if \(!boostEnabled\(\)\) return NextResponse\.json\(\{ enabled: false \}/); + assert.match(tokenPage, /const boostable = chain === BOOST_CHAIN && boostEnabled\(\);/); +}); + +test("a failed lane read never breaks a page or blanks open tabs", () => { + assert.match(server, /\.catch\(\(err\) => \{\n\s*console\.warn\("\[boost\] lane read failed:"/); + assert.match(liveRoute, /boost: boost \?\? undefined/); + assert.match(home, //); + assert.match(lane, /if \(snap\.boost\) setLane\(snap\.boost\);/); + // the lane sits after the facts-only board, never inside it + assert.ok(home.indexOf(" { + assert.match(indexer, /const twig = TWIG_ADDRESSES\[chain\];\n\s*if \(twig\) \{\n\s*const burnLogs = await fetchLogsSplit\(\(f, t\) => client\.getLogs\(\{ address: twig as Address, event: ERC20_TRANSFER_EVENT, args: \{ to: DEAD \}, fromBlock: f, toBlock: t \}\), from, to\);/); + assert.match(indexer, /applyBoost\(db, chain, l as Log & \{ args: TransferArgs \}, locker\.toLowerCase\(\)\)/); + assert.match(indexer, /applyBoost\(db, chain, l as unknown as Log & \{ args: TransferArgs \}, locker\)/); + assert.match(indexer, /INSERT INTO bb_boosts \(chain_id, tx_hash, log_index, booster, amount, tag, block_number, block_time\)[\s\S]*?ON CONFLICT DO NOTHING/); +}); + +test("moderation is admin-only, signed over its own message, with a single-use nonce", () => { + const body = server.slice(server.indexOf("export async function moderateBoost(")); + const adminAt = body.indexOf("if (!adminWallets().has(wallet)) return fail(\"admin only\", 403);"); + const verifyAt = body.indexOf("await verify(BOOST_CHAIN, wallet, message, p.signature)"); + const nonceAt = body.indexOf("await consumeNonce(p.nonce, wallet)"); + const writeAt = body.indexOf("INSERT INTO bb_token_settings"); + assert.ok(adminAt > 0 && adminAt < verifyAt && verifyAt < nonceAt && nonceAt < writeAt); + assert.match(body, /const message = buildBoostModMessage\(/); + // a hidden token is out of the lane, and its page offers no button + assert.match(server, /AND NOT COALESCE\(s\.boost_hidden, false\)/); + assert.match(panel, /\{state\.hidden \? \(\n\s*]*>\{BOOST_COPY\.hidden\}<\/p>\n\s*\) : \(/); +}); + +function walk(dir: string, out: string[] = []): string[] { + for (const name of readdirSync(dir)) { + const p = path.join(dir, name); + if (statSync(p).isDirectory()) walk(p, out); + else if (/\.(ts|tsx)$/.test(p)) out.push(p); + } + return out; +} diff --git a/app/src/components/launchpad/useBoostOn.ts b/app/src/components/launchpad/useBoostOn.ts new file mode 100644 index 00000000..c9cd432e --- /dev/null +++ b/app/src/components/launchpad/useBoostOn.ts @@ -0,0 +1,22 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useLive } from "./LiveProvider"; + +/** + * Whether the BOOST switch is on, as the shared live poll last said. No request of its own. False until a poll says + * otherwise (the server render and the first client render agree), and a poll from a machine that predates boosts + * changes nothing. Switching BOOST off reaches open tabs within one poll. + */ +export function useBoostOn(): boolean { + const { live, subscribe } = useLive(); + const [on, setOn] = useState(live.boost?.enabled === true); + useEffect( + () => + subscribe((snap) => { + if (snap.boost) setOn(snap.boost.enabled); + }), + [subscribe], + ); + return on; +} diff --git a/app/src/lib/command-palette.ts b/app/src/lib/command-palette.ts index 6c2dcdb4..c01a7fa9 100644 --- a/app/src/lib/command-palette.ts +++ b/app/src/lib/command-palette.ts @@ -58,15 +58,18 @@ export function paletteTokenFrom(l: LaunchLike): PaletteToken { /** Listed only while the Earn switch is on (GET /api/earn); it lands on the /me card. */ export const EARN_PAGE = { id: "page:earn", label: "Earn on idle USDC", href: "/me#earn", keywords: "earn lend deposit usdc vault morpho interest savings withdraw" } as const; +/** Listed only while the Boost switch is on (the live poll says so); it lands on the home page's Boosted lane. */ +export const BOOST_PAGE = { id: "page:boosted", label: "Boosted tokens", href: "/#boosted", keywords: "boost boosted twig burn lane dead" } as const; + /** * Results in reading order: tokens first (search hits, or what is trending before anything is * typed), then pages, then actions. Empty groups are dropped. */ -export function buildPaletteGroups({ query, tokens, trending, theme, earn = false }: { query: string; tokens: PaletteToken[]; trending: PaletteToken[]; theme: "light" | "dark"; earn?: boolean }): PaletteGroup[] { +export function buildPaletteGroups({ query, tokens, trending, theme, earn = false, boost = false }: { query: string; tokens: PaletteToken[]; trending: PaletteToken[]; theme: "light" | "dark"; earn?: boolean; boost?: boolean }): PaletteGroup[] { const q = query.trim(); const tokenList = q ? (isSearchableQuery(q) ? tokens : []) : trending; const tokenItems: PaletteItem[] = tokenList.map((t) => ({ kind: "token", id: `token:${t.chain}:${t.token}`, label: t.name, token: t })); - const pages: PaletteItem[] = [...PALETTE_PAGES, ...(earn ? [EARN_PAGE] : [])].filter((p) => !q || matchesQuery(q, `${p.label} ${p.keywords}`)).map((p) => ({ kind: "page", ...p })); + const pages: PaletteItem[] = [...PALETTE_PAGES, ...(earn ? [EARN_PAGE] : []), ...(boost ? [BOOST_PAGE] : [])].filter((p) => !q || matchesQuery(q, `${p.label} ${p.keywords}`)).map((p) => ({ kind: "page", ...p })); const next = theme === "dark" ? "light" : "dark"; const actions: PaletteItem[] = [ { kind: "action" as const, id: "action:bridge", label: "Bridge funds between chains", action: "bridge" as const, keywords: "bridge relay move deposit base robinhood arc" }, diff --git a/app/src/lib/launchpad/abi.ts b/app/src/lib/launchpad/abi.ts index 2abaaea8..f0f728b6 100644 --- a/app/src/lib/launchpad/abi.ts +++ b/app/src/lib/launchpad/abi.ts @@ -357,6 +357,20 @@ export const ERC20_MIN_ABI = [ }, ] as const; +/** ERC-20 transfer, alone: the one call a boost makes (TWIG to 0x…dEaD, boost.ts). Kept apart so no other flow can reach it by accident. */ +export const ERC20_TRANSFER_ABI = [ + { + type: "function", + name: "transfer", + stateMutability: "nonpayable", + inputs: [ + { name: "to", type: "address" }, + { name: "value", type: "uint256" }, + ], + outputs: [{ name: "", type: "bool" }], + }, +] as const; + /** Typed event items (viem getLogs wants AbiEvent, not an ABI index). */ export const LAUNCHED_EVENT = LAUNCH_FACTORY_ABI.find((x) => x.type === "event" && x.name === "Launched")! as Extract<(typeof LAUNCH_FACTORY_ABI)[number], { type: "event" }>; export const POOL_SWAP_EVENT = POOL_MANAGER_ABI[0]; diff --git a/app/src/lib/launchpad/boost.test.ts b/app/src/lib/launchpad/boost.test.ts new file mode 100644 index 00000000..a9d73471 --- /dev/null +++ b/app/src/lib/launchpad/boost.test.ts @@ -0,0 +1,179 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + BOOST_COPY, + BOOST_DEAD, + BOOST_FLOOR_TWIG, + BOOST_LANE_SIZE, + BOOST_PRESETS, + BOOST_TOKEN, + BOOST_WINDOW_MS, + LAUNCH_TAG_MAX, + TAG_SPACE, + boostAmount, + boostAmountProblem, + boostConfirmLabel, + boostExpiresAt, + boostFromTransfer, + boostTagOf, + boostTimeLeft, + boostWholeOf, + buildBoostModMessage, + fmtBoostTwig, + groupWhole, + isBoostModAction, + isLaunchTag, + maxBoostWhole, + parseBoostWhole, + rankLane, + type BoostTransfer, +} from "./boost.ts"; +import { TWIG_ADDRESS, TWIG_ADDRESSES } from "./twig.ts"; +import { DEAD } from "./config.ts"; + +const E18 = 10n ** 18n; +const ID = 3_135_968n; // a real Base position id (the newest TWIG launch on 2026-10-14) + +test("boosts burn TWIG on Base, to the dead address the rest of the app uses", () => { + assert.equal(BOOST_TOKEN, TWIG_ADDRESS); + assert.equal(BOOST_TOKEN, TWIG_ADDRESSES.base); + assert.equal(BOOST_DEAD, DEAD.toLowerCase()); + assert.equal(BOOST_WINDOW_MS, 24 * 3_600_000); + assert.equal(BOOST_LANE_SIZE, 5); +}); + +test("the amount carries the launch's position id in its last digits, and decodes back", () => { + const raw = boostAmount(300_000n, ID); + assert.equal(raw, 300_000n * E18 + ID); + assert.equal(boostTagOf(raw), ID); + assert.equal(boostWholeOf(raw), 300_000n); + // the tag is worth under a millionth of one TWIG + assert.ok(TAG_SPACE * 1_000_000n <= E18); +}); + +test("a tag must name a launch: positive and below the reserved range", () => { + assert.equal(isLaunchTag(0n), false); + assert.equal(isLaunchTag(1n), true); + assert.equal(isLaunchTag(LAUNCH_TAG_MAX - 1n), true); + assert.equal(isLaunchTag(LAUNCH_TAG_MAX), false); + assert.throws(() => boostAmount(100_000n, 0n)); + assert.throws(() => boostAmount(100_000n, LAUNCH_TAG_MAX)); + assert.throws(() => boostAmount(0n, ID)); +}); + +test("Max never asks for more than the balance holds, tag included", () => { + assert.equal(maxBoostWhole(500_000n * E18 + ID, ID), 500_000n); + assert.equal(maxBoostWhole(500_000n * E18 + ID - 1n, ID), 499_999n); + assert.equal(maxBoostWhole(500_000n * E18, ID), 499_999n); + assert.equal(maxBoostWhole(0n, ID), 0n); + assert.equal(maxBoostWhole(E18 - 1n, ID), 0n); + for (const bal of [123n * E18 + 5n, 10n ** 24n, 7n * E18 + ID, 7n * E18 + ID + 1n]) { + const w = maxBoostWhole(bal, ID); + if (w > 0n) assert.ok(boostAmount(w, ID) <= bal, `${bal}`); + assert.ok(boostAmount(w + 1n, ID) > bal, `${bal}`); + } +}); + +test("the amount box takes whole TWIG only", () => { + assert.equal(parseBoostWhole("300000"), 300_000n); + assert.equal(parseBoostWhole("1,000,000"), 1_000_000n); + assert.equal(parseBoostWhole(" 5 000 000 "), 5_000_000n); + for (const bad of ["", "0", "1.5", "-3", "1e6", "abc", "0x10", "1000000000000"]) assert.equal(parseBoostWhole(bad), null, bad); + for (const p of BOOST_PRESETS) assert.ok((parseBoostWhole(p) ?? 0n) >= BOOST_FLOOR_TWIG, p); +}); + +test("the dialog names the one thing stopping a send", () => { + assert.equal(boostAmountProblem(null, undefined, ID), "Enter a whole number of TWIG."); + assert.equal(boostAmountProblem(99_999n, 10n ** 30n, ID), "A boost sends at least 100,000 TWIG."); + assert.equal(boostAmountProblem(100_000n, 100_000n * E18, ID), "Not enough TWIG in this wallet."); + assert.equal(boostAmountProblem(100_000n, 100_000n * E18 + ID, ID), null); + assert.equal(boostAmountProblem(100_000n, undefined, ID), null); // balance still loading: the wallet will refuse if short +}); + +const transfer = (o: Partial = {}): BoostTransfer => ({ + address: "0x6AC18BCF4EDE02591D4917452700EA5EAAEA13C1", + from: "0xAbC0000000000000000000000000000000000001", + to: "0x000000000000000000000000000000000000dEaD", + value: 300_000n * E18 + ID, + txHash: "0xAA".padEnd(66, "0"), + logIndex: 7, + blockNumber: 52_000_000n, + ...o, +}); + +test("a TWIG transfer to 0x…dEaD with a launch tag becomes a boost row, lowercased", () => { + assert.deepEqual(boostFromTransfer(transfer()), { + tx_hash: "0xaa".padEnd(66, "0"), + log_index: 7, + booster: "0xabc0000000000000000000000000000000000001", + amount: (300_000n * E18 + ID).toString(), + tag: ID.toString(), + block_number: "52000000", + }); +}); + +test("everything else is not a boost", () => { + const LOCKER = "0x1111111111111111111111111111111111111111"; + assert.equal(boostFromTransfer(transfer({ address: "0x5f980dcfc4c0fa3911554cf5ab288ed0eb13dba3" })), null, "GITLAWB, not TWIG"); + assert.equal(boostFromTransfer(transfer({ to: "0x0000000000000000000000000000000000000000" })), null, "unwrap burns go to zero"); + assert.equal(boostFromTransfer(transfer({ to: "0xabc0000000000000000000000000000000000002" })), null, "a plain transfer"); + assert.equal(boostFromTransfer(transfer({ from: LOCKER.toUpperCase().replace("0X", "0x") }), [LOCKER]), null, "the locker's own fee burn"); + assert.equal(boostFromTransfer(transfer({ value: 300_000n * E18 })), null, "no tag"); + assert.equal(boostFromTransfer(transfer({ value: 300_000n * E18 + LAUNCH_TAG_MAX + 5n })), null, "a reserved tag"); + assert.equal(boostFromTransfer(transfer({ value: (BOOST_FLOOR_TWIG - 1n) * E18 + ID })), null, "under the floor"); + assert.ok(boostFromTransfer(transfer({ value: BOOST_FLOOR_TWIG * E18 + ID })), "exactly the floor counts"); +}); + +const row = (token: string, twig: bigint, lastAt: string) => ({ token, twig: (twig * E18).toString(), boosts: 1, boosters: 1, last_at: lastAt, last_tx: "0x" }); + +test("the lane orders by TWIG in the window, then the newer boost, then the address", () => { + const ranked = rankLane([ + row("0xc", 100_000n, "2026-10-15T01:00:00Z"), + row("0xa", 5_000_000n, "2026-10-15T00:00:00Z"), + row("0xb", 100_000n, "2026-10-15T02:00:00Z"), + row("0xe", 100_000n, "2026-10-15T01:00:00Z"), + row("0xd", 300_000n, "2026-10-14T23:00:00Z"), + row("0xf", 100_000n, "2026-10-14T00:00:00Z"), + ]); + assert.deepEqual(ranked.map((r) => r.token), ["0xa", "0xd", "0xb", "0xc", "0xe"]); + assert.equal(rankLane([row("0xa", 1n, "2026-10-15T00:00:00Z")], 0).length, 0); +}); + +test("a boost runs 24 hours from the newest one, and says so", () => { + const last = "2026-10-15T00:00:00.000Z"; + const t0 = Date.parse(last); + assert.equal(boostExpiresAt(last), t0 + 24 * 3_600_000); + assert.equal(boostTimeLeft(last, t0), "24h left"); + assert.equal(boostTimeLeft(last, t0 + 18.5 * 3_600_000), "5h left"); + assert.equal(boostTimeLeft(last, t0 + 23.25 * 3_600_000), "45m left"); + assert.equal(boostTimeLeft(last, t0 + 24 * 3_600_000 - 30_000), "under 1m left"); + assert.equal(boostTimeLeft(last, t0 + 24 * 3_600_000), null); + assert.equal(boostTimeLeft("not a date", t0), null); +}); + +test("amounts read as people say them", () => { + assert.equal(groupWhole(1_000_000n), "1,000,000"); + assert.equal(groupWhole(999n), "999"); + assert.equal(fmtBoostTwig(1_200_000n * E18 + ID), "1.2M TWIG"); + assert.equal(fmtBoostTwig(1_250_000n * E18), "1.25M TWIG"); + assert.equal(fmtBoostTwig(10_000_000n * E18), "10M TWIG"); + assert.equal(fmtBoostTwig((300_000n * E18 + ID).toString()), "300K TWIG"); + assert.equal(fmtBoostTwig(500n * E18), "500 TWIG"); + assert.equal(boostConfirmLabel(300_000n), "Send 300,000 TWIG to 0x…dEaD"); +}); + +test("an admin signature names the action, the token and the admin, and cannot pass for a posts signature", () => { + assert.ok(isBoostModAction("hide") && isBoostModAction("unhide")); + assert.equal(isBoostModAction("mute"), false); + const m = buildBoostModMessage({ action: "hide", token: "0xABC", wallet: "0xDEF", nonce: "n1", ts: Date.parse("2026-10-15T00:00:00Z") }); + assert.equal(m, ["openlaunch.lol boosted lane", "", "Action: hide", "Token: base:0xabc", "Admin: 0xdef", "Nonce: n1", "Issued: 2026-10-15T00:00:00.000Z"].join("\n")); + assert.ok(!m.includes("post:")); +}); + +test("the words keep to the rules: no value talk, the burn and its finality said plainly", () => { + const banned = /\b(moon|rally|buyback|guarantee[ds]?|pump|gem|100x|profit|invest(ment)?|returns?|yield|airdrop|reward)\b/i; + for (const [k, v] of Object.entries(BOOST_COPY)) assert.ok(!banned.test(v), `${k}: ${v}`); + assert.match(BOOST_COPY.final, /cannot be undone or refunded/); + assert.match(BOOST_COPY.laneNote, /Not a ranking/); + assert.match(BOOST_COPY.what, /not an endorsement/); +}); diff --git a/app/src/lib/launchpad/boost.ts b/app/src/lib/launchpad/boost.ts new file mode 100644 index 00000000..cfadbbc5 --- /dev/null +++ b/app/src/lib/launchpad/boost.ts @@ -0,0 +1,191 @@ +/** + * Boost: TWIG sent to 0x…dEaD buys a token a place in the Boosted lane for 24 hours. Pure, node --test loads it directly. + * + * One rule, stated once: + * A boost is one plain TWIG.transfer(0x…dEaD, amount) on Base whose amount names the launch in its last digits: + * amount = whole TWIG × 10^18 + the launch's position id (bb_launches.token_id, unique on Base's PositionManager). + * The tag is worth less than 10^-6 TWIG, so wallets show the whole amount. It lives in the Transfer event itself: + * the indexer reads logs only, and a plain wallet, a smart wallet, a Safe and an agent all boost the same way. + * + * The Boosted lane is a separate, labelled surface. It is never read by the Live sort, the Trending board or + * ranking.ts (boost-ui.test.ts guards that): a token cannot buy its way into the facts-only ranking. + * Nothing reaches Twigpine: the TWIG is gone, and the GITLAWB behind it stays in the TWIG contract. + */ +import { TWIG_ADDRESS, TWIG_DECIMALS, TWIG_SYMBOL } from "./twig.ts"; +import { CHAIN_IDS } from "../chainKeys.ts"; +import { fmtCompact } from "./math.ts"; + +/** Boosts exist on Base only: TWIG has not been bridged anywhere. */ +export const BOOST_CHAIN = "base" as const; +export const BOOST_CHAIN_ID = CHAIN_IDS[BOOST_CHAIN]; +/** Lowercase, like every address the indexer compares. */ +export const BOOST_DEAD = "0x000000000000000000000000000000000000dead"; +export const BOOST_TOKEN = TWIG_ADDRESS; +/** The least a boost may send, in whole TWIG. In TWIG, not dollars, so a pushed TWIG price cannot move it. */ +export const BOOST_FLOOR_TWIG = 100_000n; +export const BOOST_WINDOW_H = 24; +export const BOOST_WINDOW_MS = BOOST_WINDOW_H * 3_600_000; +/** Cards in the lane: the leader and four, like the Trending board. */ +export const BOOST_LANE_SIZE = 5; +export const BOOST_PRESETS = ["100000", "300000", "1000000", "5000000"] as const; +/** The largest whole amount the dialog accepts: more than every TWIG in existence, so it only stops typos. */ +export const BOOST_MAX_TWIG = 10n ** 11n; + +const UNIT = 10n ** BigInt(TWIG_DECIMALS); +/** The last 12 digits of a boost's raw amount are its tag. */ +export const TAG_SPACE = 10n ** 12n; +/** Tags below this name a launch by its position id; tags from here up are reserved for later kinds (pinned posts, names). */ +export const LAUNCH_TAG_MAX = 10n ** 11n; + +/** True for a position id a boost can carry in its last digits. */ +export function isLaunchTag(tag: bigint): boolean { + return tag > 0n && tag < LAUNCH_TAG_MAX; +} + +/** The raw amount for a boost of `whole` TWIG on the launch with position id `tag`. */ +export function boostAmount(whole: bigint, tag: bigint): bigint { + if (whole < 1n) throw new Error("a boost sends at least 1 TWIG"); + if (!isLaunchTag(tag)) throw new Error("not a launch tag"); + return whole * UNIT + tag; +} + +/** What a raw amount names: its tag (last 12 digits). */ +export function boostTagOf(raw: bigint): bigint { + return raw % TAG_SPACE; +} + +/** Whole TWIG in a raw amount (the tag is far below one TWIG). */ +export function boostWholeOf(raw: bigint): bigint { + return raw / UNIT; +} + +/** The most whole TWIG a balance can boost with, the tag included; 0 when it cannot pay one TWIG and the tag. */ +export function maxBoostWhole(balance: bigint, tag: bigint): bigint { + let w = balance / UNIT; + if (w > 0n && w * UNIT + tag > balance) w -= 1n; + return w; +} + +/** The dialog's amount box: whole TWIG only, digits with optional grouping commas, at least 1, at most BOOST_MAX_TWIG. */ +export function parseBoostWhole(input: string): bigint | null { + const s = input.replace(/[,\s_]/g, ""); + if (!/^\d{1,12}$/.test(s)) return null; + const n = BigInt(s); + return n >= 1n && n <= BOOST_MAX_TWIG ? n : null; +} + +/** Why an amount cannot be sent yet, or null when it can. */ +export function boostAmountProblem(whole: bigint | null, balance: bigint | undefined, tag: bigint): string | null { + if (whole === null) return "Enter a whole number of TWIG."; + if (whole < BOOST_FLOOR_TWIG) return `A boost sends at least ${groupWhole(BOOST_FLOOR_TWIG)} TWIG.`; + if (balance !== undefined && boostAmount(whole, tag) > balance) return "Not enough TWIG in this wallet."; + return null; +} + +/** "1,000,000": every digit, grouped (what the wallet will be asked to send, never rounded). */ +export function groupWhole(n: bigint): string { + return n.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); +} + +/** "1.2M TWIG", "300K TWIG" from a raw amount (lane cards, the token page line). */ +export function fmtBoostTwig(raw: bigint | string): string { + const whole = Number(boostWholeOf(BigInt(raw))); + const short = whole >= 1000 ? fmtCompact(whole, 2).replace(/\.?0+([KMBT])$/, "$1") : whole.toString(); + return `${short} ${TWIG_SYMBOL}`; +} + +/* ─────────────────────────────── the indexer's half ─────────────────────────────── */ + +/** One ERC-20 Transfer log, as the indexer hands it over (addresses in any case). */ +export type BoostTransfer = { address: string; from: string; to: string; value: bigint; txHash: string; logIndex: number; blockNumber: bigint }; +/** The row a boost becomes: raw facts only. The launch is joined at read time, by tag. */ +export type BoostRow = { tx_hash: string; log_index: number; booster: string; amount: string; tag: string; block_number: string }; + +/** + * A Transfer log → a boost row, or null when it is not one: not TWIG, not to 0x…dEaD, sent by an address in `skip` + * (the launch locker's own fee burns), a tag that names no launch, or less than the floor. + */ +export function boostFromTransfer(t: BoostTransfer, skip: readonly string[] = []): BoostRow | null { + if (t.address.toLowerCase() !== BOOST_TOKEN) return null; + if (t.to.toLowerCase() !== BOOST_DEAD) return null; + const booster = t.from.toLowerCase(); + if (skip.some((s) => s.toLowerCase() === booster)) return null; + const tag = boostTagOf(t.value); + if (!isLaunchTag(tag)) return null; + if (boostWholeOf(t.value) < BOOST_FLOOR_TWIG) return null; + return { tx_hash: t.txHash.toLowerCase(), log_index: t.logIndex, booster, amount: t.value.toString(), tag: tag.toString(), block_number: t.blockNumber.toString() }; +} + +/* ─────────────────────────────── the lane ─────────────────────────────── */ + +/** A token's boosts inside the window, summed (the database groups; this orders). */ +export type LaneTotals = { token: string; twig: string; boosts: number; boosters: number; last_at: string; last_tx: string }; + +/** Most TWIG in the window first; a tie goes to the newer boost, then to the address, so the order never flickers. */ +export function rankLane(rows: readonly T[], size = BOOST_LANE_SIZE): T[] { + return [...rows] + .sort((a, b) => { + const d = BigInt(b.twig) - BigInt(a.twig); + if (d !== 0n) return d > 0n ? 1 : -1; + const t = Date.parse(b.last_at) - Date.parse(a.last_at); + if (t !== 0) return t; + return a.token < b.token ? -1 : a.token > b.token ? 1 : 0; + }) + .slice(0, size); +} + +/** When a token leaves the lane: 24 hours after its newest boost. */ +export function boostExpiresAt(lastAt: string): number { + return Date.parse(lastAt) + BOOST_WINDOW_MS; +} + +/** "23h left", "45m left", "under 1m left"; null once the boost has run out. */ +export function boostTimeLeft(lastAt: string, nowMs: number): string | null { + const ms = boostExpiresAt(lastAt) - nowMs; + if (!(ms > 0)) return null; + const m = Math.floor(ms / 60_000); + if (m < 1) return "under 1m left"; + if (m < 60) return `${m}m left`; + return `${Math.floor(m / 60)}h left`; +} + +/* ─────────────────────────────── moderation ─────────────────────────────── */ + +export const BOOST_MOD_ACTIONS = ["hide", "unhide"] as const; +export type BoostModAction = (typeof BOOST_MOD_ACTIONS)[number]; +export function isBoostModAction(v: unknown): v is BoostModAction { + return typeof v === "string" && (BOOST_MOD_ACTIONS as readonly string[]).includes(v); +} + +/** The text an admin wallet signs to hide a token from the lane or show it again. Its own wording, so a posts signature never replays here. */ +export function buildBoostModMessage(p: { action: BoostModAction; token: string; wallet: string; nonce: string; ts: number }): string { + return [ + `openlaunch.lol boosted lane`, + ``, + `Action: ${p.action}`, + `Token: ${BOOST_CHAIN}:${p.token.toLowerCase()}`, + `Admin: ${p.wallet.toLowerCase()}`, + `Nonce: ${p.nonce}`, + `Issued: ${new Date(p.ts).toISOString()}`, + ].join("\n"); +} + +/* ─────────────────────────────── words ─────────────────────────────── */ + +/** Every sentence the feature shows, in one place, so a test can hold them to the rules (no value talk). */ +export const BOOST_COPY = { + laneTitle: "Boosted", + laneNote: "Paid in TWIG sent to 0x…dEaD. Not a ranking: the Live sort and Trending count outside wallets only.", + laneEmpty: "Nothing is boosted right now. Any Base launch can be boosted with TWIG from its page.", + what: "A boost puts this token in the Boosted lane for 24 hours, ordered by TWIG sent in that time. 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.", + hidden: "This token is not shown in the Boosted lane.", + baseOnly: "Boosts are on Base only: TWIG exists only there.", + how: "Anyone can boost any Base launch from its page, from 100,000 TWIG. Boosts on one token in the same 24 hours add up.", +} as const; + +/** The confirm button's words for an amount. */ +export function boostConfirmLabel(whole: bigint): string { + return `Send ${groupWhole(whole)} TWIG to 0x…dEaD`; +} diff --git a/app/src/lib/launchpad/boostServer.ts b/app/src/lib/launchpad/boostServer.ts new file mode 100644 index 00000000..d287d878 --- /dev/null +++ b/app/src/lib/launchpad/boostServer.ts @@ -0,0 +1,153 @@ +import "server-only"; +import { isAddress } from "viem"; +import { maybeDb } from "@/lib/db"; +import { getLaunchesByRefs, type LaunchRow } from "./queries"; +import { memo } from "./memo"; +import { tsFresh } from "./posts"; +import { adminWallets, consumeNonce, isNonce, verify } from "./postsServer"; +import { + BOOST_CHAIN, + BOOST_CHAIN_ID, + BOOST_FLOOR_TWIG, + BOOST_LANE_SIZE, + BOOST_WINDOW_H, + boostExpiresAt, + buildBoostModMessage, + isBoostModAction, + rankLane, + type LaneTotals, +} from "./boost"; + +/** + * Server half of Boost (boost.ts holds the rule). The feature is OFF unless the runtime secret BOOST=on is set + * (`fly secrets set BOOST=on`, which restarts the app; no rebuild). Off, the API says only `{ enabled: false }` and no + * page shows the lane, the token page line or the dialog. The indexer records boosts either way: they are TWIG burns + * on-chain whatever the switch says, and turning it on then shows the last 24 hours as they were. + */ +export function boostEnabled(): boolean { + return process.env.BOOST?.trim().toLowerCase() === "on"; +} + +export type BoostLaneItem = LaneTotals & { expires_at: string; launch: LaunchRow }; +export type BoostLane = { enabled: false } | { enabled: true; window_h: number; floor_twig: string; items: BoostLaneItem[] }; +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 }; + +const RAW_FLOOR = (BOOST_FLOOR_TWIG * 10n ** 18n).toString(); +const LANE_TTL_MS = 5_000; + +/** + * Every token's boosts inside the window, summed, for tokens not hidden from the lane, largest first. The launch is + * joined by position id (the tag), so a boost that names no launch is never shown. `limit` bounds the read. + */ +async function laneTotals(limit: number): Promise { + const db = maybeDb(); + if (!db) return []; + return db` + SELECT l.token, sum(b.amount)::text AS twig, count(*)::int AS boosts, count(DISTINCT b.booster)::int AS boosters, + max(b.block_time) AS last_at, + (array_agg(b.tx_hash ORDER BY b.block_number DESC, b.log_index DESC))[1] AS last_tx + FROM bb_boosts b + JOIN bb_launches l ON l.chain_id = b.chain_id AND l.token_id = b.tag + LEFT JOIN bb_token_settings s ON s.chain_id = l.chain_id AND s.token = l.token + WHERE b.chain_id = ${BOOST_CHAIN_ID} + AND b.block_time > now() - make_interval(hours => ${BOOST_WINDOW_H}) + AND b.amount >= ${RAW_FLOOR}::numeric + AND NOT COALESCE(s.boost_hidden, false) + GROUP BY l.token + ORDER BY sum(b.amount) DESC, max(b.block_time) DESC + LIMIT ${limit}`; +} + +/** The lane's order, shared for a few seconds by every page load and poll. */ +function rankedLane(): Promise { + return memo("boost:lane-totals", LANE_TTL_MS, async () => rankLane(await laneTotals(BOOST_LANE_SIZE * 4), BOOST_LANE_SIZE * 4)); +} + +/** 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 { + if (!boostEnabled()) return { enabled: false }; + const top = (await rankedLane()).slice(0, BOOST_LANE_SIZE); + 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 items = top.flatMap((t) => { + const launch = byToken.get(t.token); + return launch ? [{ ...t, expires_at: new Date(boostExpiresAt(t.last_at)).toISOString(), launch }] : []; + }); + return { enabled: true, window_h: BOOST_WINDOW_H, floor_twig: BOOST_FLOOR_TWIG.toString(), items }; +} + +/** + * The lane for a page render or a live poll, shared for a few seconds; null when it could not be read. Never the reason + * a page fails: a page then shows no lane, and a poll sends none, so open tabs keep what they show until the next tick. + */ +export async function boostLaneOrNull(ethUsd: number | null): Promise { + if (!boostEnabled()) return { enabled: false }; + return memo("boost:lane", LANE_TTL_MS, () => getBoostLane(ethUsd)).catch((err) => { + console.warn("[boost] lane read failed:", err instanceof Error ? err.message : err); + return 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. */ +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 }; + if (!db) return none; + 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, + (array_agg(b.tx_hash ORDER BY b.block_number DESC, b.log_index DESC))[1] AS last_tx + FROM bb_boosts b + JOIN bb_launches l ON l.chain_id = b.chain_id AND l.token_id = b.tag + WHERE b.chain_id = ${BOOST_CHAIN_ID} AND l.token = ${t} + AND b.block_time > now() - make_interval(hours => ${BOOST_WINDOW_H}) + 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(), + ]); + const i = ranked.findIndex((r) => r.token === t); + return { + enabled: true, + hidden: setting?.boost_hidden === true, + twig: row?.twig ?? "0", + boosts: row?.boosts ?? 0, + boosters: row?.boosters ?? 0, + last_at: row?.last_at ?? null, + last_tx: row?.last_tx ?? null, + expires_at: row?.last_at ? new Date(boostExpiresAt(row.last_at)).toISOString() : null, + place: i >= 0 && i < BOOST_LANE_SIZE ? i + 1 : null, + }; +} + +type Fail = { ok: false; error: string; status: number }; +const fail = (error: string, status: number): Fail => ({ ok: false, error, status }); + +/** + * Admin hide / unhide of one Base token in the Boosted lane: an ADMIN_WALLETS signature over buildBoostModMessage with + * a single-use nonce and a fresh timestamp. Boosts already sent stay recorded and are never refunded; a hidden token + * simply does not appear, and its page says so (so nobody burns TWIG for a place it cannot get). + */ +export async function moderateBoost(p: { action: unknown; token: unknown; wallet: unknown; nonce: unknown; ts: unknown; signature: unknown }): Promise<{ ok: true } | Fail> { + const db = maybeDb(); + if (!db) return fail("db unconfigured", 503); + if (typeof p.wallet !== "string" || !isAddress(p.wallet)) return fail("bad address", 400); + const wallet = p.wallet.toLowerCase(); + if (!isBoostModAction(p.action)) return fail("bad action", 400); + if (typeof p.token !== "string" || !isAddress(p.token)) return fail("bad token", 400); + const token = p.token.toLowerCase(); + if (!isNonce(p.nonce) || !tsFresh(p.ts, Date.now())) return fail("stale or bad nonce", 400); + if (!adminWallets().has(wallet)) return fail("admin only", 403); + const [known] = await db<{ token: string }[]>`SELECT token FROM bb_launches WHERE chain_id = ${BOOST_CHAIN_ID} AND token = ${token}`; + if (!known) return fail("unknown token", 404); + const message = buildBoostModMessage({ action: p.action, token, wallet, nonce: p.nonce, ts: Number(p.ts) }); + if (!(await verify(BOOST_CHAIN, wallet, message, p.signature))) return fail("signature does not match", 401); + if (!(await consumeNonce(p.nonce, wallet))) return fail("nonce already used", 401); + await db`INSERT INTO bb_token_settings (chain_id, token, boost_hidden) VALUES (${BOOST_CHAIN_ID}, ${token}, ${p.action === "hide"}) + ON CONFLICT (chain_id, token) DO UPDATE SET boost_hidden = EXCLUDED.boost_hidden, updated_at = now()`; + return { ok: true }; +} diff --git a/app/src/lib/launchpad/indexer.ts b/app/src/lib/launchpad/indexer.ts index b9f41313..74a67fd0 100644 --- a/app/src/lib/launchpad/indexer.ts +++ b/app/src/lib/launchpad/indexer.ts @@ -4,13 +4,15 @@ import { publicClient } from "@/lib/chain"; import { chainIdOf, type ChainKey } from "@/lib/chainPublic"; import { maybeDb, errMessage, type Db } from "@/lib/db"; import { LAUNCH_FACTORY_ABI, LAUNCH_LOCKER_ABI, POOL_MANAGER_ABI, ERC20_MIN_ABI, ERC20_TRANSFER_EVENT, LAUNCHED_EVENT, POOL_SWAP_EVENT, LOCKER_EVENTS } from "./abi"; -import { CONFIGURED_CHAINS, launchpad, listedQuoteAddresses } from "./config"; +import { CONFIGURED_CHAINS, DEAD, launchpad, listedQuoteAddresses } from "./config"; import { validDecimals } from "./unlisted-quote"; import { DEAD_ADDR, ZERO_ADDR } from "./holders"; import { SYNC_CHUNK_BLOCKS, SYNC_MAX_CHUNKS_PER_CALL, syncOverlapBlocks } from "@/lib/config"; import { byChainOrder, fetchBySelectors, fetchLogsSplit, isRangeTooLarge } from "./log-range"; import { redactUrls } from "./redact"; import { ENTRY_POINTS, attributeSwap, type ReceiptLog } from "./attribution"; +import { TWIG_ADDRESSES } from "./twig"; +import { boostFromTransfer } from "./boost"; /** * Launchpad chain → Postgres indexer. @@ -22,6 +24,7 @@ import { ENTRY_POINTS, attributeSwap, type ReceiptLog } from "./attribution"; * LaunchFactory.Launched → bb_launches (+ name/symbol read from the token) * PoolManager.Swap (id ∈ our pool ids) → bb_launch_swaps + rolling price / volume on bb_launches * LaunchLocker.Collected/Burned/Credited/Claimed → bb_launch_fee_events + fee totals + * TWIG.Transfer to 0x…dEaD naming a launch (Base) → bb_boosts (boost.ts; the locker's own fee burns are skipped) * Everything is idempotent on (tx_hash, log_index). */ export type LaunchSyncResult = { @@ -33,6 +36,7 @@ export type LaunchSyncResult = { launches?: number; swaps?: number; fees?: number; + boosts?: number; caught_up?: boolean; }; @@ -200,6 +204,24 @@ async function applySwap(db: Db, chain: ChainKey, log: Log & { args: Swap }, poo }) as Promise; } +type TransferArgs = { from: Address; to: Address; value: bigint }; + +/** Record one TWIG transfer to 0x…dEaD as a boost when its amount names a launch (boost.ts). The launch locker's own fee burns are never boosts. */ +async function applyBoost(db: Db, chain: ChainKey, log: Log & { args: TransferArgs }, locker: string): Promise { + const row = boostFromTransfer( + { address: log.address, from: log.args.from, to: log.args.to, value: log.args.value, txHash: log.transactionHash!, logIndex: Number(log.logIndex!), blockNumber: log.blockNumber! }, + [locker], + ); + if (!row) return false; + const time = await timeOf(chain, log.blockNumber!); + const rows = await db` + INSERT INTO bb_boosts (chain_id, tx_hash, log_index, booster, amount, tag, block_number, block_time) + VALUES (${chainIdOf(chain)}, ${row.tx_hash}, ${row.log_index}, ${row.booster}, ${row.amount}, ${row.tag}, ${row.block_number}, ${time}) + ON CONFLICT DO NOTHING + RETURNING tx_hash`; + return rows.length > 0; +} + type FeeLog = Log & ( | { eventName: "Collected"; args: { tokenId: bigint; token: Address; quoteAmount: bigint; tokenAmount: bigint } } @@ -280,7 +302,7 @@ async function poolMaps(db: Db, chain: ChainKey): Promise<{ poolToToken: Map([["robinhood", 1000]]); /** Apply every launchpad-relevant log in one range. Launched first so same-range swaps resolve. */ -async function applyRange(db: Db, chain: ChainKey, from: bigint, to: bigint): Promise<{ launches: number; swaps: number; fees: number }> { +async function applyRange(db: Db, chain: ChainKey, from: bigint, to: bigint): Promise<{ launches: number; swaps: number; fees: number; boosts: number }> { const client = publicClient(chain); const cfg = launchpad(chain); const factory = cfg.factory!; @@ -306,10 +328,17 @@ async function applyRange(db: Db, chain: ChainKey, from: bigint, to: bigint): Pr } const feeLogs = await fetchLogsSplit((f, t) => client.getLogs({ address: locker, events: LOCKER_EVENTS, fromBlock: f, toBlock: t }), from, to); for (const l of feeLogs) if (await applyFee(db, chain, l as unknown as FeeLog, tokenIdToToken)) fees++; + // boosts: TWIG sent to 0x…dEaD, one filtered log query per range, only where TWIG exists (Base) + let boosts = 0; + const twig = TWIG_ADDRESSES[chain]; + if (twig) { + const burnLogs = await fetchLogsSplit((f, t) => client.getLogs({ address: twig as Address, event: ERC20_TRANSFER_EVENT, args: { to: DEAD }, fromBlock: f, toBlock: t }), from, to); + for (const l of burnLogs) if (await applyBoost(db, chain, l as Log & { args: TransferArgs }, locker.toLowerCase())) boosts++; + } // holder balances: every Transfer of every launched token (idempotent; backfill covers history) const tokens = [...new Set([...poolToToken.values()])]; if (tokens.length > 0) await applyTransfers(db, chain, tokens, from, to); - return { launches, swaps, fees }; + return { launches, swaps, fees, boosts }; } // ── swap attribution backlog ───────────────────────────────────────────────── @@ -767,7 +796,7 @@ async function run(chain: ChainKey): Promise { const deploy = launchDeployBlock(chain); let from = cursor_block > 0n ? cursor_block - syncOverlapBlocks() : deploy; if (from < deploy) from = deploy; - const totals = { launches: 0, swaps: 0, fees: 0 }; + const totals = { launches: 0, swaps: 0, fees: 0, boosts: 0 }; let chunks = 0; let to = from; while (from <= confirmed && chunks < SYNC_MAX_CHUNKS_PER_CALL) { @@ -777,6 +806,7 @@ async function run(chain: ChainKey): Promise { totals.launches += r.launches; totals.swaps += r.swaps; totals.fees += r.fees; + totals.boosts += r.boosts; await db`UPDATE bb_launch_sync_cursor SET cursor_block = ${to}, head_block = ${head}, last_run_at = now(), last_error = NULL WHERE chain_id = ${cid}`; from = to + 1n; chunks++; @@ -791,9 +821,9 @@ async function run(chain: ChainKey): Promise { } // ── receipt apply ──────────────────────────────────────────────────────────── -export type ApplyLaunchTxResult = { status: "applied" | "not_found" | "reverted" | "skipped"; reason?: string; launches?: number; swaps?: number; fees?: number; tokens?: string[] }; +export type ApplyLaunchTxResult = { status: "applied" | "not_found" | "reverted" | "skipped"; reason?: string; launches?: number; swaps?: number; fees?: number; boosts?: number; tokens?: string[] }; -/** Apply one transaction's launches, swaps and fee events straight from its receipt (right after a wallet returns). */ +/** Apply one transaction's launches, swaps, fee events and boosts straight from its receipt (right after a wallet returns). */ export async function applyLaunchTx(chain: ChainKey, hash: Hex): Promise { const reason = skipReason(chain); if (reason) return { status: "skipped", reason }; @@ -833,5 +863,11 @@ export async function applyLaunchTx(chain: ChainKey, hash: Hex): Promise l.address.toLowerCase() === locker) }); for (const l of feeLogs) if (await applyFee(db, chain, l as unknown as FeeLog, tokenIdToToken)) fees++; - return { status: "applied", launches, swaps, fees, tokens }; + let boosts = 0; + const twig = TWIG_ADDRESSES[chain]; + if (twig) { + const burnLogs = parseEventLogs({ abi: [ERC20_TRANSFER_EVENT], eventName: "Transfer", logs: receipt.logs.filter((l) => l.address.toLowerCase() === twig) }); + for (const l of burnLogs) if (await applyBoost(db, chain, l as unknown as Log & { args: TransferArgs }, locker)) boosts++; + } + return { status: "applied", launches, swaps, fees, boosts, tokens }; } diff --git a/app/src/lib/launchpad/postsServer.ts b/app/src/lib/launchpad/postsServer.ts index 68e98f3d..fd0f927a 100644 --- a/app/src/lib/launchpad/postsServer.ts +++ b/app/src/lib/launchpad/postsServer.ts @@ -25,11 +25,12 @@ export function adminWallets(): Set { return new Set((process.env.ADMIN_WALLETS ?? "").split(",").map((s) => s.trim().toLowerCase()).filter((s) => isAddress(s))); } -function isNonce(v: unknown): v is string { +export function isNonce(v: unknown): v is string { return typeof v === "string" && /^[0-9a-f]{32}$/.test(v); } -async function consumeNonce(nonce: string, wallet: string): Promise { +/** Spend a single-use signing nonce for `wallet`; false when it was already used (a replay). Shared with boost moderation. */ +export async function consumeNonce(nonce: string, wallet: string): Promise { const db = maybeDb()!; try { await db`INSERT INTO bb_post_nonces (nonce, wallet) VALUES (${nonce}, ${wallet})`; @@ -40,7 +41,8 @@ async function consumeNonce(nonce: string, wallet: string): Promise { } } -async function verify(chain: ChainKey, wallet: string, message: string, signature: unknown): Promise { +/** True when `signature` is `wallet`'s signature of `message` (EOAs and smart wallets). Shared with boost moderation. */ +export async function verify(chain: ChainKey, wallet: string, message: string, signature: unknown): Promise { if (typeof signature !== "string" || !/^0x[0-9a-fA-F]+$/.test(signature)) return false; try { return await publicClient(chain).verifyMessage({ address: wallet as Address, message, signature: signature as Hex }); diff --git a/app/src/lib/launchpad/queries.ts b/app/src/lib/launchpad/queries.ts index 2fd62b43..8c3cdeaa 100644 --- a/app/src/lib/launchpad/queries.ts +++ b/app/src/lib/launchpad/queries.ts @@ -10,6 +10,7 @@ import { gitlawbUsd } from "./gitlawbServer"; import { GITLAWB_ADDRESS, reconcileGitlawbUsd } from "./gitlawb"; import { MUSEWORLD_ADDRESS, MUSEWORLD_TWAP_WINDOW_S, timeWeightedPrice } from "./museworld"; import { gitlawbLinkedUsd } from "./twig"; +import { BOOST_CHAIN_ID } from "./boost"; import { canonicalImageUrl, pictureKey, reusedPictures, type PictureUse } from "./images"; import { GRACE_HOURS, LIVE_WINDOW_HOURS, STRIP_SIZE, rankTrending, type LiveTier } from "./ranking"; import { SNIPER_BLOCKS, SYNCED_FOREVER } from "./holders"; @@ -530,10 +531,13 @@ export type LaunchTotals = { */ gitlawb_burned: string; /** - * TWIG sent to 0x…dEaD by launches quoted in it, raw 18-dec (Base only). Kept apart from gitlawb_burned: the GITLAWB - * behind burned TWIG stays in the TWIG contract, so it is TWIG that was burned, and it is shown as such. + * TWIG sent to 0x…dEaD, raw 18-dec (Base only): the trading fees of launches quoted in it, plus boosts (boost.ts). + * Kept apart from gitlawb_burned: the GITLAWB behind burned TWIG stays in the TWIG contract, so it is TWIG that was + * burned, and it is shown as such. */ twig_burned: string; + /** The boosts' part of twig_burned, raw 18-dec (the hero's tooltip splits the two). */ + twig_boost_burned: string; by_chain: Record; }; @@ -548,16 +552,25 @@ export async function getLaunchTotals(ethUsd: number | null = null): Promise [k, { launches: 0, trades: 0, volume_quote_eth: "0", volume_quote_usdg: "0", volume_quote_usdc: "0", volume_quote_gitlawb: "0", volume_quote_twig: "0" }])) as LaunchTotals["by_chain"], }); const db = maybeDb(); if (!db) return empty(); await withStocks(); - const rows = await db<{ chain_id: number; quote: string; launches: bigint; trades: bigint; volume: string; burned: string; creators: string }[]>` + const [rows, [boosted]] = await Promise.all([ + db<{ chain_id: number; quote: string; launches: bigint; trades: bigint; volume: string; burned: string; creators: string }[]>` SELECT chain_id, quote, count(*)::bigint AS launches, COALESCE(sum(buys + sells), 0)::bigint AS trades, COALESCE(sum(volume_quote), 0)::text AS volume, COALESCE(sum(fees_quote_burned), 0)::text AS burned, COALESCE(sum(fees_quote_collected - fees_quote_burned), 0)::text AS creators - FROM bb_launches GROUP BY chain_id, quote`; + FROM bb_launches GROUP BY chain_id, quote`, + // boosts are TWIG sent to 0x…dEaD too (boost.ts): they join the TWIG burned figure, and nothing else reads them here. + // Display only, so a failed read counts none rather than taking every page's totals down with it. + db<{ burned: string }[]>`SELECT COALESCE(sum(amount), 0)::text AS burned FROM bb_boosts WHERE chain_id = ${BOOST_CHAIN_ID}`.catch((err) => { + console.warn("[totals] boost burns unread:", err instanceof Error ? err.message : err); + return [{ burned: "0" }]; + }), + ]); const t = empty(); const add = (a: string, b: string) => (BigInt(a) + BigInt(b)).toString(); for (const r of rows) { @@ -590,6 +603,8 @@ export async function getLaunchTotals(ethUsd: number | null = null): Promise`, 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 + +Always "sent to 0x…dEaD". Never a price or value claim, never "boost your price", never moon, rally, buyback, guarantee, reward, airdrop or yield (`boost.test.ts` checks every sentence). The lane says "Not a ranking". Boosted is not endorsed, and the dialog says so. + +## Development and verification + +``` +cd app +npx tsx --conditions=react-server --tsconfig tsconfig.test.json --test src/lib/launchpad/boost.test.ts src/components/launchpad/boost-ui.test.ts +BOOST=on npm run dev +``` + +On an anvil fork of Base, a wallet with TWIG can boost any launch the local database knows; the receipt path and the indexer path both record it. Weekly figures: + +```sql +SELECT date_trunc('week', block_time) AS wk, count(*) AS boosts, count(DISTINCT booster) AS boosters, sum(amount) / 1e18 AS twig + FROM bb_boosts WHERE chain_id = 8453 GROUP BY 1 ORDER BY 1 DESC; +``` From 67854e6c4ef3d03b24dc1ca78da4fe9811a41e25 Mon Sep 17 00:00:00 2001 From: Kevin Codex Date: Thu, 15 Oct 2026 09:38:17 +0800 Subject: [PATCH 2/3] feat(boost): fuse and ignite on boosted cards; fix review findings The lane's motion follows the Trending board: nothing moves on its own. - fuse: a warm line along each card's bottom edge, the share of its 24 hours left, stepped with the card's clock - ignite: when a poll shows a card with more TWIG and a boost under 3 minutes old, one inset warm ring, the amount rolling up, a few embers; places slide. Reduced motion keeps only the colour ring. Review fixes: - copy no longer promises a place: the lane shows the 5 largest totals; the dialog says before sending when a full lane would leave the token under the 5th place and offers the amount that shows it now; the done screen names the place or says it is not in the lane yet - a sync that indexed a boost and an admin hide drop the lane cache, so the booster's place and the admin list are fresh; the panel reads once more after 6 s for the other machine; the admin list drops a hidden card at once - hero totals count boosts only while BOOST is on and only when they name a launch (switch moved to boostSwitch.ts so queries.ts needs no lane import) - the live poll carries one boost_on boolean on every page (seeded by the layout, kept across a tick from an older machine); the lane's cards come only with ?boost=1 while a lane is on screen - the lane backfills from spare candidates instead of leaving a hole - /base keeps the lane mounted so a switch-on reaches an open tab - compact amounts never read "1000K" --- app/src/app/api/launch/live/route.ts | 13 ++- app/src/app/api/launch/sync/route.ts | 6 +- app/src/app/globals.css | 9 ++ app/src/app/layout.tsx | 3 +- app/src/components/launchpad/BoostAdmin.tsx | 2 + app/src/components/launchpad/BoostDialog.tsx | 37 ++++++-- app/src/components/launchpad/BoostPanel.tsx | 21 ++++- app/src/components/launchpad/BoostedLane.tsx | 91 +++++++++++++++++-- app/src/components/launchpad/ChainLanding.tsx | 3 +- app/src/components/launchpad/LiveProvider.tsx | 23 ++++- app/src/components/launchpad/boost-ui.test.ts | 63 +++++++++++-- app/src/components/launchpad/useBoostOn.ts | 8 +- app/src/lib/launchpad/boost.test.ts | 50 ++++++++++ app/src/lib/launchpad/boost.ts | 58 ++++++++++-- app/src/lib/launchpad/boostServer.ts | 51 ++++++++--- app/src/lib/launchpad/boostSwitch.ts | 9 ++ app/src/lib/launchpad/memo.ts | 5 + app/src/lib/launchpad/queries.ts | 20 ++-- docs/boost.md | 15 ++- 19 files changed, 411 insertions(+), 76 deletions(-) create mode 100644 app/src/lib/launchpad/boostSwitch.ts diff --git a/app/src/app/api/launch/live/route.ts b/app/src/app/api/launch/live/route.ts index 138a1086..cc32155a 100644 --- a/app/src/app/api/launch/live/route.ts +++ b/app/src/app/api/launch/live/route.ts @@ -7,11 +7,15 @@ import { ethUsd } from "@/lib/launchpad/ethPrice"; import { memo } from "@/lib/launchpad/memo"; import { listFeed } from "@/lib/launchpad/postsServer"; import { FEED_POSTS_LIMIT, feedPostsKey } from "@/lib/launchpad/posts-paging"; -import { boostLaneOrNull } from "@/lib/launchpad/boostServer"; +import { boostEnabled, boostLaneOrNull } from "@/lib/launchpad/boostServer"; export const dynamic = "force-dynamic"; -/** One request for everything live on a page (feed, totals, optional list, the Boosted lane). Polled every ~5s by LiveProvider. */ +/** + * One request for everything live on a page (feed, totals, optional list). Polled every ~5s by LiveProvider. Every + * answer says whether BOOST is on (one boolean); the Boosted lane itself comes only with ?boost=1, which LiveProvider + * adds while a lane is on screen, so other pages never download its cards. + */ export async function GET(req: Request) { const u = new URL(req.url); const sort = parseSort(u.searchParams.get("sort"), null); @@ -22,6 +26,7 @@ export async function GET(req: Request) { const f = u.searchParams.get("filter"); const filter = isFilter(f) ? f : null; const limit = clampLimit(u.searchParams.get("limit"), 40); + const wantsBoost = u.searchParams.get("boost") === "1"; try { const usd = await ethUsd(); const listOpts = sort ? { sort, window, chain, filter, limit, offset: 0, ethUsd: usd } : null; @@ -35,10 +40,10 @@ export async function GET(req: Request) { 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 - boostLaneOrNull(usd), + wantsBoost ? boostLaneOrNull(usd) : Promise.resolve(null), ]); const trending = fetchedTrending ?? trendingFrom(page?.items ?? []); - return NextResponse.json({ at: Date.now(), feed, totals, ethUsd: usd, sort, window, chain, filter, limit, has_more: page?.hasMore ?? null, launches: page?.items ?? null, posts, trending, boost: boost ?? undefined }, { headers: { "cache-control": "no-store" } }); + return NextResponse.json({ at: Date.now(), feed, totals, ethUsd: usd, sort, window, chain, filter, limit, has_more: page?.hasMore ?? null, launches: page?.items ?? null, posts, trending, boost_on: boostEnabled(), boost: boost ?? undefined }, { headers: { "cache-control": "no-store" } }); } catch (err) { console.error("[launch] live failed:", err instanceof Error ? err.message : err); return NextResponse.json({ error: "could not load live snapshot" }, { status: 502 }); diff --git a/app/src/app/api/launch/sync/route.ts b/app/src/app/api/launch/sync/route.ts index 9d2b060b..6b6b5ef0 100644 --- a/app/src/app/api/launch/sync/route.ts +++ b/app/src/app/api/launch/sync/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { DEFAULT_CHAIN, isChainKey } from "@/lib/chainPublic"; import { rateLimited } from "@/lib/launchpad/editServer"; import { applyLaunchTx, pollAll } from "@/lib/launchpad/indexer"; +import { forgetBoostLane } from "@/lib/launchpad/boostServer"; export const dynamic = "force-dynamic"; @@ -19,7 +20,10 @@ export async function POST(req: Request) { if (!/^0x[0-9a-fA-F]{64}$/.test(tx)) return NextResponse.json({ error: "bad tx" }, { status: 400 }); if (!isChainKey(chain)) return NextResponse.json({ error: "bad chain" }, { status: 400 }); try { - return NextResponse.json(await applyLaunchTx(chain, tx as `0x${string}`)); + const r = await applyLaunchTx(chain, tx as `0x${string}`); + // a boost just indexed: this machine's next lane read is fresh, so the booster sees their place at once + if (r.boosts) forgetBoostLane(); + return NextResponse.json(r); } catch (err) { return NextResponse.json({ error: err instanceof Error ? err.message : "sync failed" }, { status: 502 }); } diff --git a/app/src/app/globals.css b/app/src/app/globals.css index 14dbe376..4423f0bd 100644 --- a/app/src/app/globals.css +++ b/app/src/app/globals.css @@ -350,6 +350,15 @@ input[type="number"] { -moz-appearance: textfield; appearance: textfield; } .bb-card-sell { animation: bb-card-sell 2.2s ease-out 1; } @media (prefers-reduced-motion: reduce) { .bb-row-new, .bb-row-buy, .bb-row-sell, .bb-card-new, .bb-card-buy, .bb-card-sell { animation: none; } } +/* Boosted lane (BoostedLane.tsx): a card lights up once when a boost lands on it. Decorative: the amounts carry the + facts. The ring is inset, so the lane's clipped corners never cut it. Reduced motion keeps the colour, drops the + embers and the pop. */ +@keyframes bb-boost-ring { 0% { box-shadow: inset 0 0 0 2px var(--color-warm), inset 0 0 36px -10px var(--color-warm); } 100% { box-shadow: inset 0 0 0 2px transparent, inset 0 0 36px -10px transparent; } } +.bb-boost-ring { animation: bb-boost-ring 2.4s ease-out 1 both; } +@keyframes bb-ember { 0% { opacity: 0; transform: translate(0, 0) scale(1); } 12% { opacity: 1; } 100% { opacity: 0; transform: translate(var(--dx), var(--dy)) scale(0.3); } } +.bb-ember { animation: bb-ember 1.2s cubic-bezier(0.2, 0.7, 0.3, 1) 1 both; box-shadow: 0 0 6px var(--color-warm); } +@media (prefers-reduced-motion: reduce) { .bb-ember { display: none; } } + /* ── loading system: route progress bar, skeleton shimmer, spinner ─────────── */ /* scaleX rather than width: the bar animates on the compositor instead of re-running layout on every frame. transform-origin pins it to the left edge. */ diff --git a/app/src/app/layout.tsx b/app/src/app/layout.tsx index 0571eeb9..5f927e71 100644 --- a/app/src/app/layout.tsx +++ b/app/src/app/layout.tsx @@ -7,6 +7,7 @@ import TxToasts from "@/components/launchpad/TxToasts"; import LiveProvider from "@/components/launchpad/LiveProvider"; import { getLaunchFeed, getLaunchTotalsForRequest } from "@/lib/launchpad/queries"; import { ethUsd } from "@/lib/launchpad/ethPrice"; +import { boostEnabled } from "@/lib/launchpad/boostSwitch"; import { SITE_URL } from "@/lib/chainPublic"; import { BRAND, BRAND_DOMAIN, BRAND_GITHUB, BRAND_X, SITE_DESCRIPTION, SITE_TITLE, SOCIAL_DESCRIPTION } from "@/lib/brand"; import { siteJsonLdHtml, siteVerification } from "@/lib/seo"; @@ -49,7 +50,7 @@ export default async function RootLayout({ children }: { children: React.ReactNo