Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions app/db/schema.sql
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
-- basebid.lol · free token launchpad on Base.
--
-- Applied by scripts/migrate.mjs (Fly release_command) against the DEDICATED
Expand Down Expand Up @@ -448,3 +448,29 @@
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

-- ── Boosted lane views + clicks (2026-10-16): counted only while the token is in the lane ──
-- Shown as each lane card's click count and on /admin; never read by the ranking (boost-ui.test.ts). visitor = sha256(presence visitor hash | token), cut
-- short: the presence hash rotates every UTC day and cannot be reversed, and mixing in the token means one person's
-- clicks on two tokens cannot be linked. One row per visitor, token and 30-minute slot, so a reload counts once.
CREATE TABLE IF NOT EXISTS bb_boost_clicks (
chain_id integer NOT NULL,
token text NOT NULL,
visitor text NOT NULL,
slot timestamptz NOT NULL, -- the 30-minute slot the click fell in
place integer NOT NULL CHECK (place >= 1), -- the token's place in the lane then, from the server's lane
source text NOT NULL CHECK (source IN ('home', 'base')),
clicked_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (chain_id, token, visitor, slot)
);
CREATE INDEX IF NOT EXISTS bb_boost_clicks_time_idx ON bb_boost_clicks (chain_id, clicked_at DESC);
CREATE INDEX IF NOT EXISTS bb_boost_clicks_token_time_idx ON bb_boost_clicks (chain_id, token, clicked_at DESC); -- each card's 24 h count
-- One count per token and hour: each page load that brings the lane on screen adds 1 to every token in it.
CREATE TABLE IF NOT EXISTS bb_boost_views (
chain_id integer NOT NULL,
token text NOT NULL,
hour_at timestamptz NOT NULL,
views integer NOT NULL DEFAULT 0,
PRIMARY KEY (chain_id, token, hour_at)
);
CREATE INDEX IF NOT EXISTS bb_boost_views_time_idx ON bb_boost_views (chain_id, hour_at DESC);
2 changes: 1 addition & 1 deletion app/src/app/(home)/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ 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 */}
<TrendingStrip initial={trending} serverNow={now} seenKeys={feed.filter((item) => item.kind === "swap").map(feedKey)} />
{/* paid and labelled, after the facts-only board and never inside it (boost.ts); nothing while BOOST is off */}
<BoostedLane initial={boost ?? { enabled: false }} serverNow={now} />
<BoostedLane initial={boost ?? { enabled: false }} serverNow={now} source="home" />
<LiveRiver initial={feed.filter((item) => inRiverWindow(item, now))} recent={feed} serverNow={now} coveredSince={riverCoverage(feed, RIVER_SEED, now)} lastActivityAt={feed[0] ? new Date(feed[0].at).getTime() : 0} />
<div className="grid items-start gap-x-8 gap-y-6 xl:grid-cols-[minmax(0,1fr)_19rem]">
<MagicCard className="min-w-0 rounded-2xl"><LaunchList frame="rounded-[inherit]" initial={page.items} initialHasMore={page.hasMore} initialSort={sort} initialWindow={window} initialChain={chain} initialFilter={filter} initialView={sp.view === "watchlist" ? "watchlist" : "market"} initialLayout={layout} hasDb={dbConfigured()} serverNow={now} /></MagicCard>
Expand Down
36 changes: 36 additions & 0 deletions app/src/app/api/boost/seen/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { NextResponse } from "next/server";
import { rateLimited } from "@/lib/launchpad/editServer";
import { clientIp, readJson } from "@/lib/profiles/http";
import { looksLikeBot, visitorHash } from "@/lib/launchpad/presence";
import { ethUsd } from "@/lib/launchpad/ethPrice";
import { boostBeaconSameOrigin, parseBoostSeen } from "@/lib/launchpad/boost";
import { recordBoostSeen } from "@/lib/launchpad/boostServer";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

/**
* POST {kind: "view" | "click", source: "home" | "base", token?}: the Boosted lane's beacon (sendBeacon, so the body
* comes as text/plain). A view adds 1 to every token in the lane now; a click counts once per visitor, token and 30
* minutes, and only while the token is in the lane. Only the site's own pages may send it: the counts are public on
* the cards, and another site could otherwise send them from its visitors' browsers. Nothing is stored that names a
* person: see presence.ts and boostServer.ts. 204 whether it counted or not; the page never waits on it.
*/
export async function POST(req: Request) {
const ua = req.headers.get("user-agent") ?? "";
if (looksLikeBot(ua)) return new NextResponse(null, { status: 204 });
if (!boostBeaconSameOrigin(req.headers.get("sec-fetch-site"), req.headers.get("origin"), req.headers.get("host"))) {
return NextResponse.json({ error: "cross-site" }, { status: 403 });
}
const ip = clientIp(req);
// a person sends one view per page and a click per card opened; 60 a minute leaves room for a shared address
if (rateLimited(`boost-seen:ip:${ip}`, 60)) return NextResponse.json({ error: "slow down" }, { status: 429 });
const seen = parseBoostSeen(await readJson(req, 512));
if (!seen) return NextResponse.json({ error: "bad beacon" }, { status: 400 });
try {
await recordBoostSeen(seen, visitorHash(ip, ua), await ethUsd());
} catch (err) {
console.warn("[boost] beacon failed:", err instanceof Error ? err.message : err);
}
return new NextResponse(null, { status: 204 });
}
28 changes: 28 additions & 0 deletions app/src/app/api/boost/stats/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import { rateLimited } from "@/lib/launchpad/editServer";
import { clientIp } from "@/lib/profiles/http";
import { adminWallets } from "@/lib/launchpad/postsServer";
import { isBoostStatsDays } from "@/lib/launchpad/boost";
import { getBoostStats } from "@/lib/launchpad/boostServer";

export const dynamic = "force-dynamic";

/**
* GET /api/boost/stats?wallet=0x…&days=1|7|30 → the Boosted lane's views and clicks per token, with the TWIG burned for
* each in the same period. Counts, nothing that names a person; listed only for admin wallets to keep it out of
* casual view, like the reported posts queue.
*/
export async function GET(req: Request) {
if (rateLimited(`boost-stats:ip:${clientIp(req)}`, 30)) return NextResponse.json({ error: "slow down" }, { status: 429 });
const u = new URL(req.url);
const wallet = (u.searchParams.get("wallet") ?? "").toLowerCase();
if (!adminWallets().has(wallet)) return NextResponse.json({ error: "admin only" }, { status: 403 });
const days = Number(u.searchParams.get("days") ?? "7");
if (!isBoostStatsDays(days)) return NextResponse.json({ error: "bad days" }, { status: 400 });
try {
return NextResponse.json({ days, ...(await getBoostStats(days)) }, { headers: { "cache-control": "no-store" } });
} catch (err) {
console.error("[boost] stats read failed:", err instanceof Error ? err.message : err);
return NextResponse.json({ error: "could not load stats" }, { status: 502 });
}
}
11 changes: 11 additions & 0 deletions app/src/app/api/write-limits.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ for (
{ file: "./launch/sync/route.ts", bucket: "sync:ip", mustPrecede: "pollAll()" },
{ file: "./launch/meta/route.ts", bucket: "meta:ip", mustPrecede: "req.json()" },
{ file: "./presence/route.ts", bucket: "presence:ip", mustPrecede: "recordBeacon(" },
{ file: "./boost/seen/route.ts", bucket: "boost-seen:ip", mustPrecede: "readJson(" },
]
) {
test(`${file} rate-limits by IP before doing work`, () => {
Expand All @@ -43,3 +44,13 @@ test("sync still validates tx/chain shapes with 400s", () => {
assert.match(src, /\{\s*error:\s*"bad tx"\s*\},\s*\{\s*status:\s*400\s*\}/);
assert.match(src, /\{\s*error:\s*"bad chain"\s*\},\s*\{\s*status:\s*400\s*\}/);
});

test("the Boosted lane beacon skips bots before the limiter, and caps its body", () => {
const src = source("./boost/seen/route.ts");
assert.ok(src.indexOf("looksLikeBot(ua)") < src.indexOf("rateLimited(`boost-seen:ip"), "bot beacons never touch the limiter or the DB");
// another site's beacons are refused before they use a bucket or reach the DB
assert.match(src, /if \(!boostBeaconSameOrigin\(req\.headers\.get\("sec-fetch-site"\), req\.headers\.get\("origin"\), req\.headers\.get\("host"\)\)\) \{\n\s*return NextResponse\.json\(\{ error: "cross-site" \}, \{ status: 403 \}\);/);
assert.ok(src.indexOf("boostBeaconSameOrigin(req") < src.indexOf("rateLimited(`boost-seen:ip"));
assert.ok(src.indexOf("rateLimited(`boost-seen:ip") < src.indexOf("recordBoostSeen("));
assert.match(src, /parseBoostSeen\(await readJson\(req, 512\)\)/);
});
2 changes: 1 addition & 1 deletion app/src/app/llms.txt/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, launch)
- GET ${SITE_URL}/api/boost?chain=base (the lane: twig, boosts, last_at, expires_at, clicks, launch)
- GET ${SITE_URL}/api/boost?chain=base&token=0x… (one token: twig in the window, place, hidden)
`;
}
Expand Down
110 changes: 107 additions & 3 deletions app/src/components/launchpad/BoostAdmin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ 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 type { BoostLane, BoostStats } from "@/lib/launchpad/boostServer";
import { BOOST_CHAIN, BOOST_STATS_DAYS, boostClickRate, boostClicksPerFloor, buildBoostModMessage, fmtBoostTwig, type BoostModAction, type BoostStatsDays } from "@/lib/launchpad/boost";
import { CHAINS } from "@/lib/chainPublic";
import { nowMs } from "@/lib/launchpad/time";
import { friendlyError } from "@/lib/errors";
Expand All @@ -17,7 +17,7 @@ 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.
* never refunds: the TWIG was burned on-chain. Below them, the lane's views and clicks per token.
*/
export default function BoostAdmin() {
const { address } = useHydratedAccount();
Expand Down Expand Up @@ -85,6 +85,110 @@ export default function BoostAdmin() {
</div>
</div>
{msg ? <p role="status" className={`text-xs ${msg.ok ? "text-up" : "text-down-ink"}`}>{msg.text}</p> : null}
{address ? <LaneStats wallet={address} /> : null}
</section>
);
}

const num = (n: number) => n.toLocaleString("en-US");

/**
* Views and clicks per token over a period, with the TWIG burned for each: whether a place in the lane brings visitors.
* A view is a page load that brought the lane on screen; a click counts once per visitor and 30 minutes, and only
* while the token was in the lane. Visitors: one per person per day.
*/
function LaneStats({ wallet }: { wallet: string }) {
const [days, setDays] = useState<BoostStatsDays>(7);
// both keyed by period: a period still loading shows "Loading…", never the last period's figures or error
const [got, setGot] = useState<{ key: string; stats: BoostStats } | null>(null);
const [failed, setFailed] = useState<{ key: string; text: string } | null>(null);
const key = `${wallet.toLowerCase()}:${days}`;
useEffect(() => {
const ac = new AbortController();
fetch(`/api/boost/stats?wallet=${wallet}&days=${days}`, { cache: "no-store", signal: ac.signal })
.then(async (res) => {
const d = (await res.json().catch(() => ({}))) as Partial<BoostStats> & { error?: string };
if (!res.ok || !d.rows || !d.total) throw new Error(d.error ?? `request failed (HTTP ${res.status})`);
setGot({ key, stats: { rows: d.rows, total: d.total } });
})
.catch((e) => {
if (!ac.signal.aborted) setFailed({ key, text: friendlyError(e) });
});
return () => ac.abort();
}, [wallet, days, key]);

const stats = got?.key === key ? got.stats : null;
const err = !stats && failed?.key === key ? failed.text : null;
const rows = stats?.rows ?? null;
const total = stats?.total;
const per = (clicks: number, twig: string) => {
const v = boostClicksPerFloor(clicks, twig);
return v === null ? "–" : v.toFixed(1);
};
return (
<div className={`${card} space-y-3 p-4`}>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-ink">Views and clicks</h3>
<div role="group" aria-label="Period" className="flex gap-1">
{BOOST_STATS_DAYS.map((d) => (
<button key={d} type="button" aria-pressed={d === days} onClick={() => setDays(d)} className={`rounded-full px-2.5 py-1 text-[11px] ${d === days ? "bg-ink text-paper" : "text-muted hover:text-ink"}`}>
{d === 1 ? "24h" : `${d}d`}
</button>
))}
</div>
</div>
<p className="text-[11px] leading-relaxed text-muted">A view is a page that brought the lane on screen. A click counts once per visitor every 30 minutes, only while the token is in the lane. Visitors: one per person per day, per token. The top {num(100)} tokens are listed; the totals cover all.</p>
{err ? <p role="status" className="text-xs text-down-ink">{err}</p> : null}
{rows === null ? (
err ? null : <p className="text-xs text-muted">Loading…</p>
) : rows.length === 0 ? (
<p className="text-xs text-muted">No views, clicks or boosts in this period.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[560px] text-left text-xs">
<thead className="text-[11px] text-muted">
<tr>
<th scope="col" className="py-1.5 pr-3 font-medium">Token</th>
<th scope="col" className="py-1.5 pr-3 text-right font-medium">Views</th>
<th scope="col" className="py-1.5 pr-3 text-right font-medium">Clicks</th>
<th scope="col" className="py-1.5 pr-3 text-right font-medium">Rate</th>
<th scope="col" className="py-1.5 pr-3 text-right font-medium">Visitors</th>
<th scope="col" className="py-1.5 pr-3 text-right font-medium">TWIG burned</th>
<th scope="col" className="py-1.5 text-right font-medium" title="Clicks for every 100,000 TWIG burned for the token in this period">Per 100K</th>
</tr>
</thead>
<tbody className="font-mono tnum">
{rows.map((r) => (
<tr key={r.token} className="border-t border-line">
<td className="max-w-[160px] truncate py-1.5 pr-3 font-sans">
<Link href={`/t/${BOOST_CHAIN}/${r.token}`} className="font-semibold text-ink hover:underline">{r.symbol ?? r.token.slice(0, 10)}</Link>
</td>
<td className="py-1.5 pr-3 text-right">{num(r.views)}</td>
<td className="py-1.5 pr-3 text-right text-ink">{num(r.clicks)}</td>
<td className="py-1.5 pr-3 text-right">{boostClickRate(r.clicks, r.views)}</td>
<td className="py-1.5 pr-3 text-right">{num(r.visitors)}</td>
<td className="py-1.5 pr-3 text-right">{BigInt(r.twig) > 0n ? fmtBoostTwig(r.twig) : "–"}</td>
<td className="py-1.5 text-right">{per(r.clicks, r.twig)}</td>
</tr>
))}
</tbody>
{total ? (
<tfoot className="font-mono tnum text-ink">
<tr className="border-t border-line-strong">
<th scope="row" className="py-1.5 pr-3 text-left font-sans font-medium">{total.tokens > rows.length ? `All ${num(total.tokens)}` : "All"}</th>
<td className="py-1.5 pr-3 text-right">{num(total.views)}</td>
<td className="py-1.5 pr-3 text-right">{num(total.clicks)}</td>
<td className="py-1.5 pr-3 text-right">{boostClickRate(total.clicks, total.views)}</td>
{/* a visitor is counted per token: one person on five cards cannot be told from five people */}
<td className="py-1.5 pr-3 text-right text-muted" title="Not added up: a visitor is counted per token">–</td>
<td className="py-1.5 pr-3 text-right">{BigInt(total.twig) > 0n ? fmtBoostTwig(total.twig) : "–"}</td>
<td className="py-1.5 text-right">{per(total.clicks, total.twig)}</td>
</tr>
</tfoot>
) : null}
</table>
</div>
)}
</div>
);
}
Loading
Loading