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
8 changes: 5 additions & 3 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 @@ -450,9 +450,11 @@
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.
-- Shown as each lane card's click count and on /admin; never read by the ranking (boost-ui.test.ts). visitor =
-- sha256(address hash | token), cut short. The address hash (the client address, an IPv6 one by its /64, with the
-- daily presence salt, no user agent) rotates every UTC day and cannot be reversed, and mixing in the token means one
-- address's clicks on two tokens cannot be linked. One row per visitor, token and 30-minute slot: one address counts
-- once per card every 30 minutes, whatever user agent it sends.
CREATE TABLE IF NOT EXISTS bb_boost_clicks (
chain_id integer NOT NULL,
token text NOT NULL,
Expand Down
7 changes: 5 additions & 2 deletions app/src/app/api/boost/seen/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ 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 { boostClickAddress } from "@/lib/launchpad/boostAddress";
import { recordBoostSeen } from "@/lib/launchpad/boostServer";

export const runtime = "nodejs";
Expand All @@ -22,13 +23,15 @@ export async function POST(req: Request) {
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);
// the address, an IPv6 one by its /64 (boostClickAddress): rotating within one's own block buys no fresh bucket
const ip = boostClickAddress(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());
// the address alone, no user agent: the caller picks that, and a click must not count again for a new one
await recordBoostSeen(seen, visitorHash(ip, ""), await ethUsd());
} catch (err) {
console.warn("[boost] beacon failed:", err instanceof Error ? err.message : err);
}
Expand Down
6 changes: 3 additions & 3 deletions app/src/components/launchpad/BoostAdmin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,8 @@ 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.
* A view is a page load that brought the lane on screen; a click counts once per address and 30 minutes, and only
* while the token was in the lane. Visitors: one per address per day.
*/
function LaneStats({ wallet }: { wallet: string }) {
const [days, setDays] = useState<BoostStatsDays>(7);
Expand Down Expand Up @@ -137,7 +137,7 @@ function LaneStats({ wallet }: { wallet: string }) {
))}
</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>
<p className="text-[11px] leading-relaxed text-muted">A view is a page that brought the lane on screen. A click counts once per address every 30 minutes, only while the token is in the lane. Visitors: one per address 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>
Expand Down
10 changes: 6 additions & 4 deletions app/src/components/launchpad/BoostedLane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,8 @@ export default function BoostedLane({ initial, serverNow, source }: { initial: B
const left = boostTimeLeft(b.last_at, now);
const fuse = boostFuse(b.last_at, now);
const lit = ignited[l.token];
// absent from a machine on the older build while a deploy rolls: no count shown, never "undefined"
const clicks = b.clicks ?? 0;
// two columns from 640px: an odd last card takes the whole row instead of leaving a hole
const lastOdd = i === items.length - 1 && items.length % 2 === 1;
// the whole card opens the token (the link's ::after covers it); only "Latest burn" sits above as its own link
Expand All @@ -169,7 +171,7 @@ export default function BoostedLane({ initial, serverNow, source }: { initial: B
<ArrowUpRight size={14} aria-hidden="true" className="pointer-events-none absolute right-3 top-3 text-warm-ink max-sm:hidden opacity-0 transition duration-150 group-hover/card:opacity-100 motion-safe:-translate-x-0.5 motion-safe:translate-y-0.5 motion-safe:group-hover/card:translate-x-0 motion-safe:group-hover/card:translate-y-0 motion-reduce:transition-none" />
<Link
href={`/t/${l.chain}/${l.token}`}
title={`${b.boosts} ${b.boosts === 1 ? "boost" : "boosts"} from ${b.boosters} ${b.boosters === 1 ? "wallet" : "wallets"} · ${b.clicks} ${b.clicks === 1 ? "click" : "clicks"} from this lane, in the last 24 hours`}
title={`${b.boosts} ${b.boosts === 1 ? "boost" : "boosts"} from ${b.boosters} ${b.boosters === 1 ? "wallet" : "wallets"} · ${clicks} ${clicks === 1 ? "click" : "clicks"} from this lane, in the last 24 hours`}
onClick={() => {
setPendingToken({ chain: l.chain, token: l.token, name: l.name, symbol: l.symbol, image: l.image_url });
sendSeen({ kind: "click", token: l.token }, source);
Expand Down Expand Up @@ -201,11 +203,11 @@ export default function BoostedLane({ initial, serverNow, source }: { initial: B
) : null}
</span>
{/* clicks from this lane in the last 24 hours; nothing until the first, so a fresh boost never reads "0" */}
{b.clicks > 0 ? (
{clicks > 0 ? (
<span className="inline-flex shrink-0 items-center gap-1 justify-self-end font-mono text-[11px] text-body tnum [grid-area:clicks]">
<MousePointerClick size={11} aria-hidden="true" className="shrink-0 text-muted" />
<LiveNumber value={b.clicks} format={CLICKS_FORMAT} />
<span className="sr-only">{b.clicks === 1 ? " click" : " clicks"} in the last 24 hours</span>
<LiveNumber value={clicks} format={CLICKS_FORMAT} />
<span className="sr-only">{clicks === 1 ? " click" : " clicks"} in the last 24 hours</span>
</span>
) : null}
<span className="shrink-0 justify-self-end text-[11px] text-muted [grid-area:left]" suppressHydrationWarning>{left}</span>
Expand Down
16 changes: 14 additions & 2 deletions app/src/components/launchpad/boost-ui.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,16 @@ test("a click counts only while the token is in the lane, at the server's place,
assert.equal((server.match(/boostTimeLeft\(/g) ?? []).length, 1, "one expiry filter on the server");
// no address, wallet or raw presence hash is stored: the visitor is the daily hash mixed with the token
assert.match(server, /createHash\("sha256"\)\.update\(`\$\{visitorHash\}\|\$\{token\}`\)\.digest\("hex"\)\.slice\(0, 32\)/);
assert.match(seenRoute, /await recordBoostSeen\(seen, visitorHash\(ip, ua\), await ethUsd\(\)\);/);
// the address alone: no user agent (the caller's to choose) goes into a click's visitor, and the bucket is the same key
assert.match(seenRoute, /const ip = boostClickAddress\(clientIp\(req\)\);/);
assert.match(seenRoute, /await recordBoostSeen\(seen, visitorHash\(ip, ""\), await ethUsd\(\)\);/);
assert.doesNotMatch(seenRoute, /visitorHash\(ip, ua\)/);
// the address key uses node:net, so it lives in a server-only module; boost.ts ships to the browser with the lane
assert.match(seenRoute, /import \{ boostClickAddress \} from "@\/lib\/launchpad\/boostAddress";/);
const address = read("../../lib/launchpad/boostAddress.ts");
assert.match(address, /^import "server-only";\nimport \{ isIP \} from "node:net";/);
assert.doesNotMatch(read("../../lib/launchpad/boost.ts"), /from "node:|boostClickAddress/);
assert.doesNotMatch(lane, /boostAddress/);
assert.match(seenRoute, /return new NextResponse\(null, \{ status: 204 \}\);\n\}/);
});

Expand All @@ -255,7 +264,10 @@ test("views and clicks are read in boostServer.ts alone; the full table only for
});

test("each card shows its clicks of the last 24 hours, never a 0, and a failed read never fails the lane", () => {
assert.match(lane, /\{b\.clicks > 0 \? \(\n\s*<span className="[^"]*\[grid-area:clicks\]">\n\s*<MousePointerClick size=\{11\} aria-hidden="true"[^>]*\/>\n\s*<LiveNumber value=\{b\.clicks\} format=\{CLICKS_FORMAT\} \/>\n\s*<span className="sr-only">\{b\.clicks === 1 \? " click" : " clicks"\} in the last 24 hours<\/span>/);
assert.match(lane, /\{clicks > 0 \? \(\n\s*<span className="[^"]*\[grid-area:clicks\]">\n\s*<MousePointerClick size=\{11\} aria-hidden="true"[^>]*\/>\n\s*<LiveNumber value=\{clicks\} format=\{CLICKS_FORMAT\} \/>\n\s*<span className="sr-only">\{clicks === 1 \? " click" : " clicks"\} in the last 24 hours<\/span>/);
// a poll from a machine still on the older build (mid-deploy) has no clicks: none shown, never "undefined"
assert.match(lane, /const clicks = b\.clicks \?\? 0;/);
assert.doesNotMatch(lane.replace("b.clicks ?? 0", ""), /b\.clicks/);
// the count is the lane's own window, read with the lane; a read that fails is no counts, not no lane
const body = server.slice(server.indexOf("async function laneClicks("), server.indexOf("export async function getBoostLane("));
assert.match(body, /AND clicked_at > now\(\) - make_interval\(hours => \$\{BOOST_WINDOW_H\}\)/);
Expand Down
38 changes: 38 additions & 0 deletions app/src/lib/launchpad/boostAddress.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { boostClickAddress } from "./boostAddress.ts";

test("IPv4 is the address itself", () => {
assert.equal(boostClickAddress("203.0.113.7"), "203.0.113.7");
assert.equal(boostClickAddress(" 203.0.113.7 "), "203.0.113.7");
assert.equal(boostClickAddress("0.0.0.0"), "0.0.0.0");
});

test("IPv6 is its /64 however it is written: a new host in one's own block adds nothing", () => {
const block = "2001:db8:abcd:12::/64";
for (const ip of ["2001:db8:abcd:12::1", "2001:0db8:abcd:0012:ffff:1:2:3", "2001:DB8:ABCD:12:0:0:0:9", "[2001:db8:abcd:12::77]", "2001:db8:abcd:12::1%eth0", "2001:db8:abcd:12::203.0.113.7"]) {
assert.equal(boostClickAddress(ip), block, ip);
}
assert.notEqual(boostClickAddress("2001:db8:abcd:13::1"), block, "the next /64 is another key");
assert.equal(boostClickAddress("2001:db8::1"), "2001:db8:0:0::/64", "compressed zeros in the network half are expanded first");
assert.equal(boostClickAddress("::1"), "0:0:0:0::/64");
assert.equal(boostClickAddress("::"), "0:0:0:0::/64");
});

test("an IPv4 client seen through IPv6 keeps its own IPv4 key, written dotted or in hex", () => {
for (const ip of ["::ffff:203.0.113.7", "0:0:0:0:0:ffff:203.0.113.7", "::ffff:cb00:7107", "0:0:0:0:0:FFFF:CB00:7107", "[::ffff:cb00:7107]"]) {
assert.equal(boostClickAddress(ip), "203.0.113.7", ip);
}
// two different IPv4 clients in hex form never share a key (the bug: both fell into 0:0:0:0::/64)
assert.equal(boostClickAddress("::ffff:c633:6401"), "198.51.100.1");
assert.notEqual(boostClickAddress("::ffff:cb00:7107"), boostClickAddress("::ffff:c633:6401"));
// only ::ffff:0:0/96 is mapped: ::fffe:… or a /96 with other leading bits is plain IPv6
assert.equal(boostClickAddress("::fffe:cb00:7107"), "0:0:0:0::/64");
assert.equal(boostClickAddress("1::ffff:cb00:7107"), "1:0:0:0::/64");
});

test("anything that is not an address is its own key, never a crash", () => {
for (const s of ["1::2::3", "not an ip", "", "203.0.113.7:443", "999.1.1.1", "2001:db8::g"]) {
assert.equal(boostClickAddress(s), s.trim().toLowerCase(), JSON.stringify(s));
}
});
43 changes: 43 additions & 0 deletions app/src/lib/launchpad/boostAddress.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import "server-only";
import { isIP } from "node:net";

/**
* What a Boosted-lane click is counted once per 30 minutes for: the client's address alone, never its user agent
* (which the caller picks, so one address could otherwise pass for a new visitor on every request).
*
* - IPv4: the address.
* - IPv4-mapped IPv6 (::ffff:0:0/96, written dotted as ::ffff:203.0.113.7 or in hex as ::ffff:cb00:7107): its IPv4
* address, so every IPv4 client keeps its own key.
* - Any other IPv6: its /64, the block one home or phone is given, so moving inside it buys nothing.
* - Anything node:net does not take for an address: itself, as its own key.
*
* People behind one shared address (an office, a mobile carrier) count once per 30 minutes: the public count leans
* low rather than high. Server-only (node:net); the lane's client code never needs it.
*/
export function boostClickAddress(ip: string): string {
const a = ip.trim().toLowerCase().replace(/^\[|\]$/g, "").split("%")[0];
const kind = isIP(a);
if (kind !== 6) return a;
const g = ipv6Groups(a);
if (!g) return a;
if (g.slice(0, 5).every((x) => x === 0) && g[5] === 0xffff) return [g[6] >> 8, g[6] & 0xff, g[7] >> 8, g[7] & 0xff].join(".");
return `${g.slice(0, 4).map((x) => x.toString(16)).join(":")}::/64`;
}

/** A valid IPv6 address (isIP said so) as its eight 16-bit groups; a dotted IPv4 tail becomes the last two. */
function ipv6Groups(a: string): number[] | null {
let s = a;
const dotted = s.match(/(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
if (dotted) {
const [b0, b1, b2, b3] = dotted.slice(1, 5).map(Number);
s = `${s.slice(0, dotted.index)}${((b0 << 8) | b1).toString(16)}:${((b2 << 8) | b3).toString(16)}`;
}
const parts = s.split("::");
if (parts.length > 2) return null;
const head = parts[0] ? parts[0].split(":") : [];
const tail = parts.length === 2 && parts[1] ? parts[1].split(":") : [];
const fill = parts.length === 2 ? 8 - head.length - tail.length : 0;
if (fill < 0) return null;
const groups = [...head, ...Array<string>(fill).fill("0"), ...tail].map((x) => parseInt(x, 16));
return groups.length === 8 && groups.every((x) => Number.isInteger(x) && x >= 0 && x <= 0xffff) ? groups : null;
}
12 changes: 9 additions & 3 deletions app/src/lib/launchpad/boostServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,11 @@ export { boostEnabled };
* 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.
*/
/** `clicks`: the card's clicks from the lane in the last 24 hours (bb_boost_clicks), the window its TWIG is counted in. */
export type BoostLaneItem = LaneTotals & { expires_at: string; launch: LaunchRow; clicks: number };
/**
* `clicks`: the card's clicks from the lane in the last 24 hours (bb_boost_clicks), the window its TWIG is counted in.
* Optional for readers: a machine still on the older build answers polls without it while a deploy rolls.
*/
export type BoostLaneItem = LaneTotals & { expires_at: string; launch: LaunchRow; clicks?: number };
export type BoostLane = { enabled: false } | { enabled: true; window_h: number; floor_twig: string; items: BoostLaneItem[] };
export type TokenBoost =
| { enabled: false }
Expand Down Expand Up @@ -235,7 +238,10 @@ async function flushViews(): Promise<void> {
}
}

/** A click's visitor: the presence hash (daily salt) mixed with the token, cut short, so two tokens' clicks never link. */
/**
* A click's visitor: the hash of the address with the daily salt (the route passes visitorHash(address, ""), no user
* agent), mixed with the token and cut short, so two tokens' clicks never link.
*/
function clickVisitor(visitorHash: string, token: string): string {
return createHash("sha256").update(`${visitorHash}|${token}`).digest("hex").slice(0, 32);
}
Expand Down
Loading
Loading