diff --git a/app/src/app/api/boost/mod/route.ts b/app/src/app/api/boost/mod/route.ts index f5c41a26..59a2c192 100644 --- a/app/src/app/api/boost/mod/route.ts +++ b/app/src/app/api/boost/mod/route.ts @@ -1,16 +1,26 @@ import { NextResponse } from "next/server"; import { rateLimited } from "@/lib/launchpad/editServer"; import { clientIp, readJson } from "@/lib/profiles/http"; -import { moderateBoost } from "@/lib/launchpad/boostServer"; +import { getBoostLane, moderateBoost } from "@/lib/launchpad/boostServer"; +import { ethUsd } from "@/lib/launchpad/ethPrice"; export const dynamic = "force-dynamic"; -/** POST {action: hide|unhide, token, wallet, nonce, ts, signature}: an admin wallet keeps a Base token out of the Boosted lane, or lets it back. */ +/** + * POST {action: hide|unhide, token, wallet, nonce, ts, signature}: an admin wallet keeps a Base token out of the Boosted + * lane, or lets it back. A signed, single-use request, so its answer may carry the lane read fresh (both few-second + * caches skipped): the panel shows the result at once, whichever machine its next read reaches. `lane` is null when + * that read fails; the write itself has succeeded. + */ export async function POST(req: Request) { if (rateLimited(`boost-mod:ip:${clientIp(req)}`, 30)) return NextResponse.json({ error: "slow down" }, { status: 429 }); const b = await readJson(req); if (!b) return NextResponse.json({ error: "bad json" }, { status: 400 }); const r = await moderateBoost({ action: b.action, token: b.token, wallet: b.wallet, nonce: b.nonce, ts: b.ts, signature: b.signature }); if (!r.ok) return NextResponse.json({ error: r.error }, { status: r.status }); - return NextResponse.json(r); + const lane = await getBoostLane(await ethUsd(), { fresh: true }).catch((err) => { + console.warn("[boost] fresh lane after moderation failed:", err instanceof Error ? err.message : err); + return null; + }); + return NextResponse.json({ ...r, lane }); } diff --git a/app/src/app/api/boost/route.ts b/app/src/app/api/boost/route.ts index d2b035ef..450f4857 100644 --- a/app/src/app/api/boost/route.ts +++ b/app/src/app/api/boost/route.ts @@ -27,7 +27,8 @@ export async function GET(req: Request) { if (!isAddress(token)) return NextResponse.json({ error: "bad token" }, { status: 400 }); return NextResponse.json(await getTokenBoost(token), { headers: NO_STORE }); } - // the same few-second cache pages and polls read: a caller polling this cannot add a database pass per request + // the same few-second cache pages and polls read: a caller polling this cannot add a database pass per request. + // The only fresh read is the signed moderation answer (api/boost/mod), right after a hide or unhide. const lane = await boostLaneOrNull(await ethUsd()); if (!lane) return NextResponse.json({ error: "could not load boosts" }, { status: 502 }); return NextResponse.json(lane, { headers: NO_STORE }); diff --git a/app/src/app/llms.txt/route.ts b/app/src/app/llms.txt/route.ts index 8d8b4be7..2269f958 100644 --- a/app/src/app/llms.txt/route.ts +++ b/app/src/app/llms.txt/route.ts @@ -15,7 +15,7 @@ To boost a launch: TWIG (${BOOST_TOKEN}).transfer(0x…dEaD, amount) with amount (whole = a count of whole TWIG, at least ${BOOST_FLOOR_TWIG}; token_id = the launch's position id, the list API's token_id field, greater than 0 and less than 10^11). The last 12 digits name the launch; a transfer outside these bounds is not a boost. Then POST ${SITE_URL}/api/launch/sync?chain=base&tx=0x… to show it at once. Burns are final. -- GET ${SITE_URL}/api/boost?chain=base (the lane: twig, boosts, last_at, expires_at, clicks, top_booster, launch; entry) +- GET ${SITE_URL}/api/boost?chain=base (the lane: twig, boosts, last_at, expires_at, clicks, top_booster, launch; names) - GET ${SITE_URL}/api/boost?chain=base&token=0x… (one token: twig in the window, place, hidden) `; } diff --git a/app/src/app/t/[chain]/[token]/page.tsx b/app/src/app/t/[chain]/[token]/page.tsx index d40eebba..0e56d295 100644 --- a/app/src/app/t/[chain]/[token]/page.tsx +++ b/app/src/app/t/[chain]/[token]/page.tsx @@ -19,6 +19,7 @@ import TokenTrades from "@/components/launchpad/TokenTrades"; import LaunchReceipt from "@/components/launchpad/LaunchReceipt"; import TokenAbout from "@/components/launchpad/TokenAbout"; import NamesProvider from "@/components/profile/NamesProvider"; +import { intentUrl } from "@/lib/profiles/xpost"; import { namesFor, type NameEntry } from "@/lib/profiles/server"; import { getHolderPanel } from "@/lib/launchpad/holdersServer"; import { memo } from "@/lib/launchpad/memo"; @@ -220,7 +221,7 @@ export default async function TokenPage({ params, searchParams }: { params: Prom {/* the token's links as one bar, the Watch button's height: share, copy the address, the explorer */}
- + {/* phones keep one row of actions; the explorer link is also in About & contracts */} {explorerName(chain)} diff --git a/app/src/components/launchpad/BoostAdmin.tsx b/app/src/components/launchpad/BoostAdmin.tsx index d8472843..6ba66c2c 100644 --- a/app/src/components/launchpad/BoostAdmin.tsx +++ b/app/src/components/launchpad/BoostAdmin.tsx @@ -48,12 +48,17 @@ export default function BoostAdmin() { const signature = await wallet.signMessage({ message: buildBoostModMessage({ action, token, wallet: address, nonce: n, ts }) }); const res = await fetch("/api/boost/mod", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ action, token, wallet: address, nonce: n, ts, signature }) }); // an error page that is not JSON (a 502 from the proxy) still reports the HTTP failure, not a parse error - const d = (await res.json().catch(() => ({}))) as { error?: string }; + const d = (await res.json().catch(() => ({}))) as { error?: string; lane?: BoostLane | null }; if (!res.ok) throw new Error(d.error ?? `request failed (HTTP ${res.status})`); setMsg({ ok: true, text: `${action === "hide" ? "Hidden" : "Shown again"}: ${token}` }); - // a hidden card leaves the list at once; the next read (fresh on the machine that took the write) confirms it - if (action === "hide") setLane((l) => (l?.enabled ? { ...l, items: l.items.filter((b) => b.token !== token.toLowerCase()) } : l)); - await load(); + // the signed answer carries the lane read fresh, so the list is right at once, whichever machine answers next; + // without it (that read failed) the card still leaves at once and a cached read follows + if (d.lane) { + setLane(d.lane); + } else { + if (action === "hide") setLane((l) => (l?.enabled ? { ...l, items: l.items.filter((b) => b.token !== token.toLowerCase()) } : l)); + await load(); + } } catch (e) { setMsg({ ok: false, text: friendlyError(e) }); } diff --git a/app/src/components/launchpad/BoostDialog.tsx b/app/src/components/launchpad/BoostDialog.tsx index 0d622e96..8f3c98bc 100644 --- a/app/src/components/launchpad/BoostDialog.tsx +++ b/app/src/components/launchpad/BoostDialog.tsx @@ -16,7 +16,7 @@ import { DEAD } from "@/lib/launchpad/config"; import { TWIG_WRAP_URL } from "@/lib/launchpad/twig"; import { fmtUsd } from "@/lib/launchpad/math"; import { friendlyError } from "@/lib/errors"; -import { BUILDER_DATA_SUFFIX, CHAINS, explorerTx } from "@/lib/chainPublic"; +import { BUILDER_DATA_SUFFIX, CHAINS, SITE_URL, explorerTx } from "@/lib/chainPublic"; import { BOOST_CHAIN, BOOST_COPY, @@ -33,6 +33,7 @@ import { isLaunchTag, maxBoostWhole, parseBoostWhole, + boostShareUrl, } from "@/lib/launchpad/boost"; import { toast } from "./TxToasts"; import { TwigMark } from "./TwigBadge"; @@ -150,6 +151,8 @@ export default function BoostDialog({ token, tokenId, symbol, name, twigUsd, cur

{fmtBoostTwig(boostAmount(phase.whole, tag))} went to 0x…dEaD. It counts for the next 24 hours and adds up with any other boost on {symbol}; the lane shows the 5 largest totals.

View transaction + {/* the booster's own post: what was sent and where it went, the token's page as its link */} + Share on X See the lane
diff --git a/app/src/components/launchpad/BoostPanel.tsx b/app/src/components/launchpad/BoostPanel.tsx index 207e29cf..2708e2a0 100644 --- a/app/src/components/launchpad/BoostPanel.tsx +++ b/app/src/components/launchpad/BoostPanel.tsx @@ -2,12 +2,12 @@ import dynamic from "next/dynamic"; import { useCallback, useEffect, useRef, useState } from "react"; -import { ArrowUpRight } from "lucide-react"; +import { ArrowUpRight, MousePointerClick } from "lucide-react"; import { btn } from "@/components/ui"; import { useLive } from "./LiveProvider"; import { TwigMark } from "./TwigBadge"; import type { TokenBoost } from "@/lib/launchpad/boostServer"; -import { BOOST_CHAIN, BOOST_COPY, boostTimeLeft, fmtBoostTwig } from "@/lib/launchpad/boost"; +import { BOOST_CHAIN, BOOST_COPY, BOOST_PANEL_CLICKS_MIN, boostTimeLeft, fmtBoostTwig } from "@/lib/launchpad/boost"; import { explorerTx } from "@/lib/chainPublic"; import type { PendingBoost } from "./BoostDialog"; @@ -15,8 +15,8 @@ import type { PendingBoost } from "./BoostDialog"; const BoostDialog = dynamic(() => import("./BoostDialog"), { ssr: false }); /** - * A Base token's boost line on its page: whether it is in the Boosted lane, with how much TWIG and for how long, and - * the button that opens the dialog. Nothing while BOOST is off. A token an admin hid says so and offers no button, so + * A Base token's boost line on its page: whether it is in the Boosted lane, with how much TWIG and for how long, what + * the lane brought it (clicks in the same 24 hours, from BOOST_PANEL_CLICKS_MIN up), and the button that opens the dialog. Nothing while BOOST is off. A token an admin hid says so and offers no button, so * nobody sends TWIG for a place it cannot get. `autoOpen` comes from /me's "Boost" link (?boost=1). */ export default function BoostPanel({ token, tokenId, symbol, name, initial, twigUsd, serverNow, autoOpen = false }: { token: string; tokenId: number; symbol: string; name: string; initial: TokenBoost; twigUsd: number | null; serverNow: number; autoOpen?: boolean }) { @@ -51,6 +51,8 @@ export default function BoostPanel({ token, tokenId, symbol, name, initial, twig if (!state.enabled) return null; const left = state.last_at ? boostTimeLeft(state.last_at, now) : null; const active = left !== null && BigInt(state.twig) > 0n; + // absent from a machine on an older build mid-deploy: no line, never "undefined" + const clicks = state.clicks ?? 0; return (
@@ -68,6 +70,13 @@ export default function BoostPanel({ token, tokenId, symbol, name, initial, twig <>Not boosted. Sending TWIG to 0x…dEaD puts {symbol} in the Boosted lane for 24 hours. )}

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

+

+ ) : null}
{active && state.last_tx ? Latest burn : null} diff --git a/app/src/components/launchpad/boost-ui.test.ts b/app/src/components/launchpad/boost-ui.test.ts index fe5e00c4..581c9e53 100644 --- a/app/src/components/launchpad/boost-ui.test.ts +++ b/app/src/components/launchpad/boost-ui.test.ts @@ -189,7 +189,7 @@ test("CodeRabbit round 1: the done screen, admin errors and the agent formula", assert.doesNotMatch(dialog, /settle\([^)]*, true\)/); assert.match(dialog, /const showsAfter = \(w: bigint\): boolean => !tagOk \|\| boostShowsNow\(entry, current, boostAmount\(w, tag\), inLane\);/); // an error page that is not JSON still reports the HTTP failure; a bad lane read never throws from the effect - assert.match(admin, /const d = \(await res\.json\(\)\.catch\(\(\) => \(\{\}\)\)\) as \{ error\?: string \};/); + assert.match(admin, /const d = \(await res\.json\(\)\.catch\(\(\) => \(\{\}\)\)\) as \{ error\?: string; lane\?: BoostLane \| null \};/); assert.match(admin, /throw new Error\(d\.error \?\? `request failed \(HTTP \$\{res\.status\}\)`\);/); assert.match(admin, /const load = useCallback\(async \(\) => \{\n\s*try \{/); // agents get the bounds the decoder enforces (isLaunchTag: 0 < tag < 10^11; whole TWIG >= the floor) @@ -345,9 +345,19 @@ test("the lane invites a boost: a header button, every open place a button, and assert.match(server, /items, names: await creditNames\(\[\.\.\.boosters\.values\(\)\]\) \};/); assert.match(server, /const known = await namesFor\(wallets\)\.catch\(\(\) => null\);\n\s*return known \? Object\.fromEntries\(wallets\.map\(\(w\) => \[w, known\[w\] \?\? null\]\)\) : \{\};/); assert.match(lane, //); - // a public read of the lane comes from the same few-second cache: polling it adds no database pass + // a public read of the lane comes from the same few-second cache: polling it adds no database pass, and no query + // parameter skips it (CodeRabbit #102: a wallet in the URL is not proof of an admin) assert.match(boostRoute, /const lane = await boostLaneOrNull\(await ethUsd\(\)\);/); - assert.doesNotMatch(boostRoute, /getBoostLane/); + assert.doesNotMatch(boostRoute, /getBoostLane|searchParams\.get\("fresh"\)|adminWallets/); + // the only fresh read is the answer to a signed, single-use moderation request, after the write + const modRoute = read("../../app/api/boost/mod/route.ts"); + assert.ok(modRoute.indexOf("if (!r.ok) return") < modRoute.indexOf("getBoostLane(await ethUsd(), { fresh: true })"), "fresh only after a verified write"); + assert.match(modRoute, /return NextResponse\.json\(\{ \.\.\.r, lane \}\);/); + assert.match(server, /const ranked = opts\.fresh \? rankLane\(await laneTotals\(BOOST_LANE_SIZE \* 4\), BOOST_LANE_SIZE \* 4\) : await rankedLane\(\);/); + // the panel takes that lane; its own reads are the cached ones everyone gets + assert.match(admin, /if \(d\.lane\) \{\n\s*setLane\(d\.lane\);\n\s*\} else \{/); + assert.match(admin, /fetch\(`\/api\/boost\?chain=\$\{BOOST_CHAIN\}`, \{ cache: "no-store" \}\)/); + assert.doesNotMatch(admin, /fresh=1|justHidden|JUST_HIDDEN/); }); test("the picker only navigates: the boost happens on the token's page, with every check the dialog has", () => { @@ -382,3 +392,33 @@ test("a card credits the wallet that sent the most TWIG for it; a failed read sh assert.match(body, /\} catch \(err\) \{\n\s*console\.warn\("\[boost\] lane boosters read failed:"[^\n]*\n\s*return new Map\(\);/); assert.match(lane, /\{b\.top_booster \? \(/); }); + + +test("the token page shows what the lane brought: clicks in the same 24 hours, from 10 up, only while boosted", () => { + assert.match(panel, /const clicks = state\.clicks \?\? 0;/); + assert.match(panel, /\{active && clicks >= BOOST_PANEL_CLICKS_MIN \? \(/); + assert.match(panel, /clicks from the lane in the last 24 hours/); + // the lane card's own count (laneClicks: same window, never throws), and only for a token boosted in the window + const body = server.slice(server.indexOf("export async function getTokenBoost("), server.indexOf("/* ─────────────────────────────── views and clicks")); + assert.match(body, /const clicks = row\?\.last_at \? \(\(await laneClicks\(\[t\]\)\)\.get\(t\) \?\? 0\) : 0;/); + assert.doesNotMatch(body, /FROM bb_boost_clicks/, "one click count for the card and the page, not two copies"); +}); + +test("after a confirmed boost the booster can share it on X: its own post, in a new tab", () => { + const done = dialog.slice(dialog.indexOf('{phase.k === "done" ? ('), dialog.indexOf(") : (")); + assert.match(done, /Share on X/); + assert.equal((dialog.match(/boostShareUrl\(/g) ?? []).length, 1, "only on the done screen"); +}); + +test("the agents' notes name the lane's fields as they are", () => { + assert.match(llms, /\(the lane: twig, boosts, last_at, expires_at, clicks, top_booster, launch; names\)/); + assert.doesNotMatch(llms, /launch; entry\)/); +}); + + + +test("one X intent builder: the booster's post and the token page's Share both go through intentUrl", () => { + assert.match(read("../../lib/launchpad/boost.ts"), /return intentUrl\(boostShareText\(symbol, raw\), \{ url: pageUrl, via: BRAND_X \}\);/); + assert.match(tokenPage, / { + const raw = 1_000_000n * E18 + ID; + assert.equal(boostShareText("MUSEWORLD", raw), "Boosted $MUSEWORLD with 1M TWIG on openlaunch.lol. The TWIG went to 0x…dEaD."); + assert.equal(boostShareText("BSTR", (426_248n * E18).toString()), "Boosted $BSTR with 426.25K TWIG on openlaunch.lol. The TWIG went to 0x…dEaD."); + const url = new URL(boostShareUrl("A&B #1", raw, "https://openlaunch.lol/t/base/0xabc")); + assert.equal(url.origin + url.pathname, "https://x.com/intent/post", "the same intent the token page's Share uses"); + assert.equal(url.searchParams.get("text"), "Boosted $A&B #1 with 1M TWIG on openlaunch.lol. The TWIG went to 0x…dEaD.", "a symbol never breaks out of its parameter"); + assert.equal(url.searchParams.get("url"), "https://openlaunch.lol/t/base/0xabc"); + assert.equal(url.searchParams.get("via"), "openlaunch_lol"); + const banned = /\b(moon|rally|buyback|guarantee[ds]?|pump|gem|100x|profit|invest(ment)?|returns?|yield|airdrop|reward)\b/i; + assert.doesNotMatch(boostShareText("X", raw), banned); +}); + +test("the token page shows its clicks from the lane only once they read like a result", () => { + assert.equal(BOOST_PANEL_CLICKS_MIN, 10); +}); diff --git a/app/src/lib/launchpad/boost.ts b/app/src/lib/launchpad/boost.ts index 587ea9e5..91428d03 100644 --- a/app/src/lib/launchpad/boost.ts +++ b/app/src/lib/launchpad/boost.ts @@ -13,6 +13,8 @@ */ import { TWIG_ADDRESS, TWIG_DECIMALS, TWIG_SYMBOL } from "./twig.ts"; import { CHAIN_IDS } from "../chainKeys.ts"; +import { BRAND_DOMAIN, BRAND_X } from "../brand.ts"; +import { intentUrl } from "../profiles/xpost.ts"; /** Boosts exist on Base only: TWIG has not been bridged anywhere. */ export const BOOST_CHAIN = "base" as const; @@ -239,6 +241,24 @@ export function topBoosters(rows: readonly BoosterTotal[]): Map return new Map([...best].map(([token, r]) => [token, r.booster])); } +/* ─────────────────────────────── results and sharing ─────────────────────────────── */ + +/** The token page shows its clicks from the lane once there are this many: a fresh boost never reads "3 clicks". */ +export const BOOST_PANEL_CLICKS_MIN = 10; + +/** + * The post a booster can share once a boost is confirmed: what was sent and where it went, nothing about price. True + * whether or not the boost brought the token into the lane yet. + */ +export function boostShareText(symbol: string, raw: bigint | string): string { + return `Boosted $${symbol} with ${fmtBoostTwig(raw)} on ${BRAND_DOMAIN}. The TWIG went to 0x…dEaD.`; +} + +/** The X intent for that post: the token's page as its link, via the official account (the one intent builder, xpost.ts). */ +export function boostShareUrl(symbol: string, raw: bigint | string, pageUrl: string): string { + return intentUrl(boostShareText(symbol, raw), { url: pageUrl, via: BRAND_X }); +} + /* ─────────────────────────────── views and clicks ─────────────────────────────── */ /** diff --git a/app/src/lib/launchpad/boostServer.ts b/app/src/lib/launchpad/boostServer.ts index 249aae32..75259819 100644 --- a/app/src/lib/launchpad/boostServer.ts +++ b/app/src/lib/launchpad/boostServer.ts @@ -49,7 +49,7 @@ export type BoostLaneItem = LaneTotals & { expires_at: string; launch: LaunchRow export type BoostLane = { enabled: false } | { enabled: true; window_h: number; floor_twig: string; items: BoostLaneItem[]; names?: Record }; export type TokenBoost = | { enabled: false } - | { enabled: true; hidden: boolean; twig: string; boosts: number; boosters: number; last_at: string | null; last_tx: string | null; expires_at: string | null; place: number | null; entry: LaneEntry }; + | { enabled: true; hidden: boolean; twig: string; boosts: number; boosters: number; last_at: string | null; last_tx: string | null; expires_at: string | null; place: number | null; entry: LaneEntry; clicks?: number }; const RAW_FLOOR = (BOOST_FLOOR_TWIG * 10n ** 18n).toString(); const LANE_TTL_MS = 5_000; @@ -153,11 +153,16 @@ async function creditNames(wallets: readonly string[]): Promise [w, known[w] ?? null])) : {}; } -/** The Boosted lane: the top BOOST_LANE_SIZE tokens with their market rows. `{ enabled: false }` while the switch is off. */ -export async function getBoostLane(ethUsd: number | null): Promise { +/** + * The Boosted lane: the top BOOST_LANE_SIZE tokens with their market rows. `{ enabled: false }` while the switch is off. + * `fresh` skips the few-second ranking memo too (the admin panel after a hide: the other machine's memos may still hold + * the token); pages and polls read it through boostLaneOrNull instead. + */ +export async function getBoostLane(ethUsd: number | null, opts: { fresh?: boolean } = {}): Promise { if (!boostEnabled()) return { enabled: false }; + const ranked = opts.fresh ? rankLane(await laneTotals(BOOST_LANE_SIZE * 4), BOOST_LANE_SIZE * 4) : await rankedLane(); // a few spare candidates, so a token whose market row cannot be read leaves its place to the next one, not a hole - const top = (await rankedLane()).slice(0, BOOST_LANE_SIZE + 3); + const top = ranked.slice(0, BOOST_LANE_SIZE + 3); const rows = top.length > 0 ? await getLaunchesByRefs(top.map((t) => ({ chain: BOOST_CHAIN, token: t.token })), ethUsd) : []; const byToken = new Map(rows.map((r) => [r.launch.token, r.launch])); const shown = top @@ -188,12 +193,16 @@ export async function boostLaneOrNull(ethUsd: number | null): Promise boostTimeLeft(b.last_at, now) !== null) }; } -/** One token's boost state for its page: TWIG inside the window, its place in the lane (1-based) and whether an admin hid it. */ +/** + * One token's boost state for its page: TWIG inside the window, its place in the lane (1-based), whether an admin hid + * it, and its clicks from the lane in the same 24 hours (the panel shows them from BOOST_PANEL_CLICKS_MIN up). `clicks` + * is optional for readers: a machine on an older build answers without it while a deploy rolls. + */ export async function getTokenBoost(token: string): Promise { if (!boostEnabled()) return { enabled: false }; const db = maybeDb(); const t = token.toLowerCase(); - const none = { enabled: true as const, hidden: false, twig: "0", boosts: 0, boosters: 0, last_at: null, last_tx: null, expires_at: null, place: null, entry: { full: false, cutoff: null } }; + const none = { enabled: true as const, hidden: false, twig: "0", boosts: 0, boosters: 0, last_at: null, last_tx: null, expires_at: null, place: null, entry: { full: false, cutoff: null }, clicks: 0 }; if (!db) return none; const [[row], [setting], ranked] = await Promise.all([ db<{ twig: string; boosts: number; boosters: number; last_at: string | null; last_tx: string | null }[]>` @@ -208,6 +217,9 @@ export async function getTokenBoost(token: string): Promise { db<{ boost_hidden: boolean }[]>`SELECT boost_hidden FROM bb_token_settings WHERE chain_id = ${BOOST_CHAIN_ID} AND token = ${t}`, rankedLane(), ]); + // the lane's own count (same window, same rows as the card); only for a token boosted in the window, since the panel + // shows nothing otherwise. laneClicks never throws: a failed read is no clicks line. + const clicks = row?.last_at ? ((await laneClicks([t])).get(t) ?? 0) : 0; const i = ranked.findIndex((r) => r.token === t); return { enabled: true, @@ -220,6 +232,7 @@ export async function getTokenBoost(token: string): Promise { expires_at: row?.last_at ? new Date(boostExpiresAt(row.last_at)).toISOString() : null, place: i >= 0 && i < BOOST_LANE_SIZE ? i + 1 : null, entry: laneEntry(ranked), + clicks, }; } diff --git a/app/src/lib/profiles/profiles.test.ts b/app/src/lib/profiles/profiles.test.ts index 0985faa2..d8dbd9ac 100644 --- a/app/src/lib/profiles/profiles.test.ts +++ b/app/src/lib/profiles/profiles.test.ts @@ -110,6 +110,7 @@ test("post texts carry the code, the brand account and the profile link; the pic } assert.equal(postTextFor("kevin", "OL-7K2QXM9A", "openlaunch_lol", "openlaunch.lol"), postTextFor("kevin", "OL-7K2QXM9A", "openlaunch_lol", "openlaunch.lol")); assert.match(intentUrl("a b\nc"), /^https:\/\/x\.com\/intent\/post\?text=a%20b%0Ac$/); + assert.equal(intentUrl("hi", { url: "https://openlaunch.lol/t/base/0xabc?x=1&y=2", via: "openlaunch_lol" }), "https://x.com/intent/post?text=hi&url=https%3A%2F%2Fopenlaunch.lol%2Ft%2Fbase%2F0xabc%3Fx%3D1%26y%3D2&via=openlaunch_lol"); }); test("oEmbed parsing: text out of the blockquote, author from author_url", () => { diff --git a/app/src/lib/profiles/xpost.ts b/app/src/lib/profiles/xpost.ts index 36b0f95e..2eb5c769 100644 --- a/app/src/lib/profiles/xpost.ts +++ b/app/src/lib/profiles/xpost.ts @@ -82,8 +82,9 @@ export function postTextFor(username: string, code: string, brandX: string, doma } /** X's compose link with the post text filled in. */ -export function intentUrl(text: string): string { - return `https://x.com/intent/post?text=${encodeURIComponent(text)}`; +export function intentUrl(text: string, extra: { url?: string; via?: string } = {}): string { + const tail = `${extra.url ? `&url=${encodeURIComponent(extra.url)}` : ""}${extra.via ? `&via=${encodeURIComponent(extra.via)}` : ""}`; + return `https://x.com/intent/post?text=${encodeURIComponent(text)}${tail}`; } /** The token X's own embed widget sends with tweet-result requests. */ diff --git a/docs/boost.md b/docs/boost.md index 7ddb1d65..cea20739 100644 --- a/docs/boost.md +++ b/docs/boost.md @@ -44,6 +44,7 @@ The lane asks for a boost where people see it, without ever sending one itself: - **"Boost a token"** in the lane's header, on every screen size. **Every open place is a button** ("+ Open spot · from 100K TWIG", one per place from 1024px; one "+ N open spots · Boost a token" row below that, so the cards stay first). Each opens the **picker** (`BoostPicker.tsx`, loaded on first use): the connected wallet's own Base launches first (their creators are the likeliest boosters), a search of Base tokens, or what is trending on Base before anything is typed. Picking a token goes to `/t/base/?boost=1#boost`, the same link /me's Boost uses, where the dialog opens with every check it has. Nothing is sent from the lane or the picker. - **What it takes to get in**, beside the title (under it on a phone): "2 open spots · from 100K TWIG" while there is room, "Full · 5th place holds 1.2M TWIG" once there is not. It is read off the cards the lane shows (`boostEntryLine`), so it never contradicts them. The token page's dialog keeps its own rule from the full ranking; the two differ only when a ranked token's market row cannot be read and its card is left out. - **Credit:** each card names the wallet that sent the most TWIG for it in the last 24 hours ("by @name ✓" with a profile, the short address otherwise; a tie goes to the newer boost, then the address). Only the name is a link, above the card's own. The lane read carries `top_booster` and the credited wallets' `names` (null for no profile), so the first paint shows the name without a swap; a failed read shows no credit and never fails the lane. The credit took the card's "Latest burn" link: the burns stay one step away, on the token page's Boost panel. +- **What the lane brought**, on the token's page: while it is boosted, the Boost panel adds "312 clicks from the lane in the last 24 hours" (`getTokenBoost` reuses the cards' own count, only for a token boosted in the window), from 10 clicks up (`BOOST_PANEL_CLICKS_MIN`) so a fresh boost never reads like a small number. A failed count shows no line. - The picker asks only for what it shows: the wallet's own Base launches through `GET /api/launch/list?launcher=0x…`, trending kept a minute across opens. A token an admin keeps out of the lane says "Not shown in the lane" in the picker instead of sending the visitor to a page with no Boost button. ## Views and clicks @@ -64,13 +65,13 @@ Only many addresses can add many clicks: one address adds at most one per card e ## The dialog -On a Base token page (or from **Boost** on a launch in /me, which opens it there): whole TWIG only, presets 100K / 300K / 1M / 5M, the balance, Max (tag included), an estimate in dollars at today's TWIG price, and the last digits the amount will carry. It states plainly that the TWIG goes to 0x…dEaD, cannot be undone or refunded, and counts toward the token's place in the lane for 24 hours without changing the ranking. When the lane is full and the amount would leave the token under the 5th place, the dialog says so before anything is sent and offers the amount that would show it now. The done screen names the token's place, or says it is not in the lane yet. +On a Base token page (or from **Boost** on a launch in /me, which opens it there): whole TWIG only, presets 100K / 300K / 1M / 5M, the balance, Max (tag included), an estimate in dollars at today's TWIG price, and the last digits the amount will carry. It states plainly that the TWIG goes to 0x…dEaD, cannot be undone or refunded, and counts toward the token's place in the lane for 24 hours without changing the ranking. When the lane is full and the amount would leave the token under the 5th place, the dialog says so before anything is sent and offers the amount that would show it now. The done screen names the token's place, or says it is not in the lane yet, and offers **Share on X**: the booster's own post, prefilled with what was sent and where it went ("Boosted $SYM with 1M TWIG on openlaunch.lol. The TWIG went to 0x…dEaD."), the token's page as its link and `via @openlaunch_lol` (`boostShareUrl`, the same x.com intent as the token page's Share). Nothing about price; the words pass the same test as the rest. Writes follow the trade panel: a synchronous lock before the first await, a network switch to Base if needed, a simulation of the exact transfer, then the wallet. **Sent means sent:** once the wallet returns a hash, only a receipt ends the action. A receipt that cannot be read leaves the dialog unconfirmed and locked, with "Check again" and the transaction link; the token page keeps that lock if the dialog is closed and reopened. A reload forgets it, so the copy says to check the wallet's activity before sending again. ## Moderation -ADMIN_WALLETS can hide a token from the lane on /admin (or show it again by address). Each action is an admin-wallet signature over a boost-specific message (`openlaunch.lol boosted lane`, the action, `base:`, the admin, a single-use nonce, a time within 5 minutes), so a posts signature can never be replayed here. Hidden is a flag in `bb_token_settings.boost_hidden`. Burns already sent stay recorded and are never refunded. A hidden token's page says it is not shown in the lane and offers no button, so nobody burns TWIG for a place it cannot get. +ADMIN_WALLETS can hide a token from the lane on /admin (or show it again by address). The signed moderation answer carries the lane read fresh (both few-second caches skipped), so the panel is right at once, whichever machine its next read reaches. No public read skips the caches: a wallet named in a URL proves nothing. Pages and polls keep reading the cached lane, so a hidden card can stay on an open page for up to about 10 seconds. Each action is an admin-wallet signature over a boost-specific message (`openlaunch.lol boosted lane`, the action, `base:`, the admin, a single-use nonce, a time within 5 minutes), so a posts signature can never be replayed here. Hidden is a flag in `bb_token_settings.boost_hidden`. Burns already sent stay recorded and are never refunded. A hidden token's page says it is not shown in the lane and offers no button, so nobody burns TWIG for a place it cannot get. ## Copy rules