diff --git a/app/db/schema.sql b/app/db/schema.sql index 3e14e218..c03a37d0 100644 --- a/app/db/schema.sql +++ b/app/db/schema.sql @@ -450,9 +450,11 @@ 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. +-- 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, diff --git a/app/src/app/api/boost/seen/route.ts b/app/src/app/api/boost/seen/route.ts index 460476c9..cb63ea49 100644 --- a/app/src/app/api/boost/seen/route.ts +++ b/app/src/app/api/boost/seen/route.ts @@ -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"; @@ -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); } diff --git a/app/src/components/launchpad/BoostAdmin.tsx b/app/src/components/launchpad/BoostAdmin.tsx index db4078bd..d8472843 100644 --- a/app/src/components/launchpad/BoostAdmin.tsx +++ b/app/src/components/launchpad/BoostAdmin.tsx @@ -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(7); @@ -137,7 +137,7 @@ function LaneStats({ wallet }: { wallet: string }) { ))} -

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.

+

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.

{err ?

{err}

: null} {rows === null ? ( err ? null :

Loading…

diff --git a/app/src/components/launchpad/BoostedLane.tsx b/app/src/components/launchpad/BoostedLane.tsx index df0da3fb..8654ab60 100644 --- a/app/src/components/launchpad/BoostedLane.tsx +++ b/app/src/components/launchpad/BoostedLane.tsx @@ -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 @@ -169,7 +171,7 @@ export default function BoostedLane({ initial, serverNow, source }: { initial: B