e\.item\)\}/);
+});
+
+test("reduced motion holds the river still and drops the arrival ping", () => {
+ const still = riverCss.slice(riverCss.indexOf("@media (prefers-reduced-motion: reduce)"));
+ assert.match(still, /\.mark \{ animation: none; transform: translateX\(calc\(var\(--age\) \* -100cqw\)\); opacity: var\(--fade\); \}/);
+ assert.match(still, /\.ping \.dot, \.ping \.avatar \{ animation: none; \}/);
+});
+
+test("a cut-off seed never claims a 30-minute total", () => {
+ assert.match(home, /coveredSince=\{riverCoverage\(feed, RIVER_SEED, now\)\}/);
+ assert.match(river, /riverSummary\(entries, complete\)/);
+});
+
+test("the Just launched rail re-reads the market model only when an event says it changed", () => {
+ assert.match(rail, /subscribe\(\(snap\) =>/);
+ assert.match(rail, /\/api\/launch\/list\?sort=new&limit=\$\{JUST_LAUNCHED_SIZE\}/);
+ assert.match(rail, /const MIN_GAP_MS = 10_000;/);
+ assert.doesNotMatch(rail, /setInterval\(\s*\(\)\s*=>\s*(?:void\s+)?load/, "no polling of its own");
+ assert.match(rail, /trades \? .*"No trades yet"/s);
+});
+
+test("the watchlist star sits outside the row link, and the tab survives a back navigation", () => {
+ assert.match(list, /\n\s*\{\/\* outside the row's link: saving never navigates \*\/\}\n\s*]*> window.localStorage.getItem(WATCHLIST_STORAGE_KEY),
+ write: (value) => window.localStorage.setItem(WATCHLIST_STORAGE_KEY, value),
+});
+let subscribers = 0;
+
+function onStorage(event: StorageEvent) {
+ if (event.key === WATCHLIST_STORAGE_KEY || event.key === null) store.refresh();
+}
+
+function subscribe(listener: () => void) {
+ const unsubscribe = store.subscribe(listener);
+ if (subscribers++ === 0) {
+ window.addEventListener("storage", onStorage);
+ window.addEventListener("focus", store.refresh);
+ store.refresh();
+ }
+ return () => {
+ unsubscribe();
+ if (--subscribers === 0) {
+ window.removeEventListener("storage", onStorage);
+ window.removeEventListener("focus", store.refresh);
+ }
+ };
+}
+
+const getServerSnapshot = () => WATCHLIST_SERVER_SNAPSHOT;
+
+/** One shared store synchronizes every star and view, with a stable server/hydration snapshot. */
+export function useWatchlist() {
+ const snapshot = useSyncExternalStore(subscribe, store.getSnapshot, getServerSnapshot);
+ return { ...snapshot, toggle: store.toggle, markSeen: store.markSeen };
+}
diff --git a/app/src/components/sections/RulesGuide.module.css b/app/src/components/sections/RulesGuide.module.css
index c9c35528..2a7f45f0 100644
--- a/app/src/components/sections/RulesGuide.module.css
+++ b/app/src/components/sections/RulesGuide.module.css
@@ -15,6 +15,7 @@
.sectionHeading { display: flex; align-items: flex-end; justify-content: space-between; gap: 24px; margin-bottom: 24px; }
.sectionHeading h2, .closing h2 { margin-top: 10px; color: var(--color-ink); font-size: 30px; font-weight: 600; line-height: 1.18; letter-spacing: -.035em; text-wrap: balance; }
.sectionHeading > p { padding-bottom: 2px; color: var(--color-muted); font-size: 12px; line-height: 1.7; }
+.machine { max-width: 40rem; margin: 0 auto 32px; }
.steps { border-block: 1px solid var(--color-line-strong); }
.step { display: grid; grid-template-columns: 44px minmax(0, 1fr) 124px; gap: 20px; align-items: start; position: relative; padding: 26px 0; }
.step + .step { border-top: 1px solid var(--color-line); }
diff --git a/app/src/lib/hero-cta.ts b/app/src/lib/hero-cta.ts
new file mode 100644
index 00000000..6c49190b
--- /dev/null
+++ b/app/src/lib/hero-cta.ts
@@ -0,0 +1,23 @@
+/**
+ * Whether the home hero's filled "Launch a token" is on screen, for the header's copy of it (one filled blue per
+ * screen). The hero reports its own visibility, so the signal survives the hero remounting after load (a light
+ * theme load can swap the streamed page for a client render, which left an observer in the header watching a
+ * detached node). Null while no hero is mounted.
+ */
+let onScreen: boolean | null = null;
+const listeners = new Set<() => void>();
+
+export function setHeroCtaOnScreen(value: boolean | null): void {
+ if (value === onScreen) return;
+ onScreen = value;
+ for (const listener of listeners) listener();
+}
+
+export function heroCtaOnScreen(): boolean | null {
+ return onScreen;
+}
+
+export function subscribeHeroCta(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => { listeners.delete(listener); };
+}
diff --git a/app/src/lib/launchpad/feed-dust-wiring.test.ts b/app/src/lib/launchpad/feed-dust-wiring.test.ts
index e66f8673..7d1ccacb 100644
--- a/app/src/lib/launchpad/feed-dust-wiring.test.ts
+++ b/app/src/lib/launchpad/feed-dust-wiring.test.ts
@@ -41,7 +41,11 @@ test("the trade table's symbol-less cell reads \"<0.01\", not \"0\", for a dust
test("the tape and the toast tracker identify a swap by its log, so two equal swaps in one tx stay two rows", () => {
const queue = readFileSync(new URL("./toast-queue.ts", import.meta.url), "utf8");
- for (const src of [tape, queue]) {
+ // the tape and the home river share one identity, feedKey in river.ts
+ const river = readFileSync(new URL("./river.ts", import.meta.url), "utf8");
+ assert.match(tape, /import \{ feedKey \} from "@\/lib\/launchpad\/river";/);
+ assert.match(tape, /const k = feedKey\(item\);/);
+ for (const src of [river, queue]) {
assert.match(src, /\$\{item\.kind === "swap" \? `:\$\{item\.log_index\}` : ""\}/);
assert.doesNotMatch(src, /:\$\{item\.quote_wei\}/);
}
diff --git a/app/src/lib/launchpad/holders.ts b/app/src/lib/launchpad/holders.ts
index 89160946..601ee4b1 100644
--- a/app/src/lib/launchpad/holders.ts
+++ b/app/src/lib/launchpad/holders.ts
@@ -7,6 +7,8 @@ export const DEAD_ADDR = "0x000000000000000000000000000000000000dead";
/** Buys in the launch block and the next SNIPER_BLOCKS blocks count as sniping. */
export const SNIPER_BLOCKS = 3;
export const TOP_HOLDERS = 10;
+/** Persisted bigint-max marker: transfer history is complete and the live loop keeps it current. */
+export const SYNCED_FOREVER = 9223372036854775807n;
/** A single wallet above this share of supply is flagged in the panel. */
export const WHALE_BPS = 500; // 5%
diff --git a/app/src/lib/launchpad/paging.ts b/app/src/lib/launchpad/paging.ts
index f535b7a8..93c7f913 100644
--- a/app/src/lib/launchpad/paging.ts
+++ b/app/src/lib/launchpad/paging.ts
@@ -1,6 +1,8 @@
/** Pure paging helpers (node --test loads this directly). */
export const PAGE_SIZE = 40;
export const MAX_LIMIT = 200;
+/** Rows in the home page's Just launched rail. */
+export const JUST_LAUNCHED_SIZE = 8;
export function clampLimit(v: unknown, fallback = PAGE_SIZE): number {
const n = Number(v);
diff --git a/app/src/lib/launchpad/queries.ts b/app/src/lib/launchpad/queries.ts
index d0a9a398..bcf8b86c 100644
--- a/app/src/lib/launchpad/queries.ts
+++ b/app/src/lib/launchpad/queries.ts
@@ -12,7 +12,7 @@ import { MUSEWORLD_ADDRESS, MUSEWORLD_TWAP_WINDOW_S, timeWeightedPrice } from ".
import { gitlawbLinkedUsd } from "./twig";
import { canonicalImageUrl } from "./images";
import { GRACE_HOURS, LIVE_WINDOW_HOURS, rankTrending, type LiveTier } from "./ranking";
-import { SNIPER_BLOCKS } from "./holders";
+import { SNIPER_BLOCKS, SYNCED_FOREVER } from "./holders";
import { imagePublicBase } from "./imageStore";
import { collectNonDust } from "./feed-dust";
import { fdvQuote, quotePerToken, tickToTokensPerQuote, units } from "./math";
@@ -387,6 +387,22 @@ export async function getLaunch(chain: ChainKey, token: string, ethUsd: number |
return rows[0] ? shape(rows[0], ethUsd) : null;
}
+/** A bounded watchlist lookup shares the market read model without one query per token. */
+export async function getLaunchesByRefs(refs: readonly { chain: ChainKey; token: string }[], ethUsd: number | null = null): Promise<{ launch: LaunchRow; holders: number | null }[]> {
+ if (refs.length > 50) throw new Error("too many launch references");
+ const db = maybeDb();
+ if (!db || refs.length === 0) return [];
+ await withStocks();
+ const matches = refs.map((ref) => db`(l.chain_id = ${chainIdOf(ref.chain)} AND l.token = ${ref.token.toLowerCase()})`);
+ const rows = await db<(Raw & { holders_synced_block: bigint | null })[]>`
+ ${db.unsafe(SELECT)} WHERE ${matches.reduce((a, b) => db`${a} OR ${b}`)}`;
+ return rows.map((r) => ({
+ launch: shape(r, ethUsd),
+ // Same completed-backfill sentinel as getHolderPanel. An unfinished index is not zero holders.
+ holders: r.holders_synced_block !== null && BigInt(r.holders_synced_block) === SYNCED_FOREVER ? Number(r.holders) : null,
+ }));
+}
+
/** Find which chain a token lives on (for the legacy /t/ redirect). */
export async function findLaunchChain(token: string): Promise {
const db = maybeDb();
diff --git a/app/src/lib/launchpad/river.test.ts b/app/src/lib/launchpad/river.test.ts
new file mode 100644
index 00000000..5a04c11b
--- /dev/null
+++ b/app/src/lib/launchpad/river.test.ts
@@ -0,0 +1,201 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import type { FeedItem } from "./queries";
+import { COMPACT_SCALE, RIVER_HEIGHT, RIVER_WINDOW_MS, feedKey, inRiverWindow, initialRiver, layoutRiver, mergeRiver, pruneRiver, riverCoverage, riverSummary, riverUsd, type RiverEntry } from "./river.ts";
+
+const NOW = Date.parse("2026-10-04T16:48:00Z");
+const iso = (msAgo: number) => new Date(NOW - msAgo).toISOString();
+const WIDTH = { wide: 1120, compact: 340 };
+
+let n = 0;
+function swap(o: { ago: number; buy?: boolean; usd?: number | null; symbol?: string; token?: string; log?: number }): FeedItem {
+ n += 1;
+ return {
+ kind: "swap", chain: "base", at: iso(o.ago), tx_hash: `0x${n.toString(16).padStart(64, "0")}`, log_index: o.log ?? 0,
+ token: o.token ?? "0xf3018d16d3c75f86df76bdeed12027c813f4e4a0", name: "Waifu", symbol: o.symbol ?? "WAIFU",
+ trader: "0x1111111111111111111111111111111111111111", is_buy: o.buy ?? true, is_dev: false, quote_wei: "1000000000000000",
+ quote_key: "eth", quote_symbol: "ETH", quote_decimals: 18, usd: o.usd === undefined ? 50 : o.usd, image_url: null,
+ };
+}
+function launch(o: { ago: number; symbol?: string; token?: string }): FeedItem {
+ n += 1;
+ return {
+ kind: "launch", chain: "robinhood", at: iso(o.ago), tx_hash: `0x${n.toString(16).padStart(64, "0")}`,
+ token: o.token ?? `0x${n.toString(16).padStart(40, "0")}`, name: o.symbol ?? "Fresh", symbol: o.symbol ?? "FRESH",
+ launcher: "0x2222222222222222222222222222222222222222", lp_fee: 10_000, quote_key: "eth", image_url: null,
+ };
+}
+const entries = (items: FeedItem[], seenAt = NOW): RiverEntry[] => initialRiver(items, seenAt);
+const only = (items: FeedItem[]) => layoutRiver(entries(items), WIDTH);
+
+test("feed keys separate logs within one transaction and kinds within one token", () => {
+ const a = swap({ ago: 1_000, log: 3 });
+ const b = { ...a, log_index: 4 };
+ assert.notEqual(feedKey(a), feedKey(b));
+ assert.equal(feedKey(a), feedKey({ ...a }));
+ const l = launch({ ago: 1_000 });
+ assert.match(feedKey(l), /^robinhood:launch:0x[0-9a-f]+:0x[0-9a-f]+$/);
+});
+
+test("the window is the last 30 minutes, with a minute of grace for a clock running ahead", () => {
+ assert.equal(inRiverWindow(swap({ ago: 0 }), NOW), true);
+ assert.equal(inRiverWindow(swap({ ago: RIVER_WINDOW_MS }), NOW), true);
+ assert.equal(inRiverWindow(swap({ ago: RIVER_WINDOW_MS + 1 }), NOW), false);
+ assert.equal(inRiverWindow(swap({ ago: -30_000 }), NOW), true, "a server clock 30s ahead keeps the trade");
+ assert.equal(inRiverWindow(swap({ ago: -120_000 }), NOW), false);
+ assert.equal(inRiverWindow({ ...swap({ ago: 0 }), at: "not a date" }, NOW), false);
+});
+
+test("the first paint holds only in-window events, newest first, none of them fresh", () => {
+ const old = swap({ ago: 40 * 60_000 });
+ const a = swap({ ago: 60_000 });
+ const b = launch({ ago: 5 * 60_000 });
+ const river = initialRiver([b, old, a, a], NOW);
+ assert.deepEqual(river.map((e) => feedKey(e.item)), [feedKey(a), feedKey(b)]);
+ assert.ok(river.every((e) => !e.fresh && e.seenAt === NOW));
+});
+
+test("a poll keeps when known events were first seen, refreshes their payload and marks arrivals fresh", () => {
+ const known = swap({ ago: 2 * 60_000, usd: null });
+ const river = initialRiver([known], NOW - 5_000);
+ const priced = { ...known, usd: 42 };
+ const arrival = swap({ ago: 1_000, buy: false });
+ const next = mergeRiver(river, [arrival, priced], NOW);
+ assert.equal(next.length, 2);
+ const [first, second] = next;
+ assert.equal(feedKey(first.item), feedKey(arrival));
+ assert.equal(first.fresh, true);
+ assert.equal(first.seenAt, NOW);
+ assert.equal(second.seenAt, NOW - 5_000, "a known event never re-seeds its animation offset");
+ assert.equal(second.fresh, false);
+ assert.equal(second.item.kind === "swap" && second.item.usd, 42, "the newer payload wins");
+});
+
+test("events leave once they pass the window; pruning without change keeps the same array", () => {
+ const leaving = swap({ ago: RIVER_WINDOW_MS - 1_000 });
+ const staying = swap({ ago: 1_000 });
+ const river = initialRiver([leaving, staying], NOW);
+ assert.equal(pruneRiver(river, NOW), river);
+ const later = pruneRiver(river, NOW + 2_000);
+ assert.deepEqual(later.map((e) => feedKey(e.item)), [feedKey(staying)]);
+ assert.equal(mergeRiver(river, [], NOW + 2_000).length, 1);
+});
+
+test("dollar labels stay short", () => {
+ assert.equal(riverUsd(0.004), "<$0.01");
+ assert.equal(riverUsd(5.2), "$5.20");
+ assert.equal(riverUsd(9.999), "$10");
+ assert.equal(riverUsd(54.4), "$54");
+ assert.equal(riverUsd(999.4), "$999");
+ assert.equal(riverUsd(999.6), "$1K");
+ assert.equal(riverUsd(1_240), "$1.2K");
+ assert.equal(riverUsd(999_400), "$999.4K");
+ assert.equal(riverUsd(999_960), "$1M", "rounds up into the next unit, never $1000K");
+ assert.equal(riverUsd(2_500_000), "$2.5M");
+ assert.equal(riverUsd(3e9), "$3B");
+ assert.equal(riverUsd(Number.NaN), "$0");
+});
+
+test("the summary counts trades and launches in plain words", () => {
+ assert.equal(riverSummary([]), "Nothing in the last 30 minutes");
+ assert.equal(riverSummary(entries([swap({ ago: 1 })])), "1 trade in the last 30 minutes");
+ assert.equal(riverSummary(entries([launch({ ago: 1 })])), "1 launch in the last 30 minutes");
+ assert.equal(riverSummary(entries([swap({ ago: 1 }), swap({ ago: 2 }), launch({ ago: 3 })])), "2 trades and 1 launch in the last 30 minutes");
+ assert.equal(riverSummary(entries([swap({ ago: 1 }), launch({ ago: 2 }), launch({ ago: 3 })])), "1 trade and 2 launches in the last 30 minutes");
+});
+
+test("a full seed whose oldest event is inside the window says latest, never a window total", () => {
+ const busy = Array.from({ length: 5 }, (_, i) => swap({ ago: i * 60_000 }));
+ assert.equal(riverCoverage(busy, 10, NOW), null, "fewer than the limit: nothing was cut off");
+ const cutAt = Date.parse(busy[4].at);
+ assert.equal(riverCoverage(busy, 5, NOW), cutAt, "full, and the oldest is 4 minutes old: older events may be missing");
+ assert.equal(riverCoverage([...busy.slice(0, 4), swap({ ago: RIVER_WINDOW_MS + 60_000 })], 5, NOW), null, "the seed reaches past the window");
+ const seen = entries(busy);
+ assert.equal(riverSummary(seen, false), "Latest 5 trades");
+ assert.equal(riverSummary(seen, true), "5 trades in the last 30 minutes");
+});
+
+test("buys rise above the line, sells sit below it, launches land on it, and size follows dollars", () => {
+ const [buy, sell, l] = only([swap({ ago: 60_000, usd: 50 }), swap({ ago: 120_000, buy: false, usd: 50 }), launch({ ago: 180_000 })]);
+ assert.equal(buy.kind, "buy");
+ assert.ok(buy.y < 0.5);
+ assert.equal(sell.kind, "sell");
+ assert.ok(sell.y > 0.5);
+ assert.ok(Math.abs(Math.abs(0.5 - buy.y) - Math.abs(sell.y - 0.5)) < 1e-9, "same size, mirrored");
+ assert.equal(l.kind, "launch");
+ assert.equal(l.y, 0.5);
+ const [small, big] = only([swap({ ago: 60_000, usd: 5 }), swap({ ago: 6 * 60_000, usd: 5_000 })]);
+ assert.ok(big.r > small.r);
+ assert.ok(big.y < small.y, "a bigger buy rises further");
+});
+
+test("even a whale stays inside both field sizes", () => {
+ for (const buy of [true, false]) {
+ const [m] = only([swap({ ago: 60_000, buy, usd: 5_000_000 })]);
+ for (const size of ["wide", "compact"] as const) {
+ const h = RIVER_HEIGHT[size];
+ const r = m.r * (size === "compact" ? COMPACT_SCALE : 1);
+ assert.ok(m.y * h - r >= 0 && m.y * h + r <= h, `${buy ? "buy" : "sell"} fits the ${size} field`);
+ }
+ }
+});
+
+test("an unpriced trade draws small and unlabelled; it never borrows a dollar figure", () => {
+ const [m] = only([swap({ ago: 60_000, usd: null })]);
+ assert.equal(m.label, null);
+ assert.deepEqual(m.showLabel, { wide: false, compact: false });
+ assert.ok(m.r < 8);
+});
+
+test("a mark's animation offset is its age when this client first saw it", () => {
+ const item = swap({ ago: 90_000 });
+ const [m] = layoutRiver(initialRiver([item], NOW), WIDTH);
+ assert.equal(m.ageAtSeen, 90_000);
+ const [future] = layoutRiver(initialRiver([swap({ ago: -20_000 })], NOW), WIDTH);
+ assert.equal(future.ageAtSeen, 0, "a trade stamped slightly ahead starts at the right edge");
+});
+
+test("a burst in one lane stacks instead of overlapping, and arrivals never move earlier marks", () => {
+ const a = swap({ ago: 30_000, usd: 50 });
+ const b = swap({ ago: 26_000, usd: 50 });
+ const c = swap({ ago: 22_000, usd: 50 });
+ const [mc, mb, ma] = only([a, b, c]);
+ assert.deepEqual([ma.nudge, mb.nudge, mc.nudge], [0, 7, 14]);
+ assert.ok(mc.y < mb.y && mb.y < ma.y, "each stacked buy sits a little higher");
+ const before = only([a, b]);
+ const after = only([a, b, c]);
+ for (const m of before) {
+ const same = after.find((x) => x.key === m.key)!;
+ assert.deepEqual([same.y, same.r, same.nudge], [m.y, m.r, m.nudge]);
+ }
+ const [lone] = only([swap({ ago: 30_000 + 60_000, usd: 50 })]);
+ assert.equal(lone.nudge, 0);
+});
+
+test("labels show where they fit: the bigger of two colliding trades wins, small ones wait for hover", () => {
+ const big = swap({ ago: 60_000, usd: 400, symbol: "BIG" });
+ const near = swap({ ago: 61_000, usd: 380, symbol: "NEAR", token: "0x5b3c2cd87083ea5c4436525dca6213740405b69e" });
+ const tiny = swap({ ago: 10 * 60_000, usd: 8, symbol: "TINY" });
+ const marks = only([big, near, tiny]);
+ const by = (s: string) => marks.find((m) => m.label?.startsWith(s))!;
+ assert.equal(by("BIG").label, "BIG $400");
+ assert.equal(by("BIG").showLabel.wide, true);
+ assert.equal(by("NEAR").showLabel.wide, false, "it would overlap BIG");
+ assert.equal(by("TINY").showLabel.wide, false, "under the $20 floor");
+ assert.equal(by("TINY").label, "TINY $8.00", "still labelled on hover");
+});
+
+test("launch labels try their side, then the other; a phone shows the mark without the name", () => {
+ const first = launch({ ago: 60_000, symbol: "ONE", token: "0x0000000000000000000000000000000000000002" });
+ const second = launch({ ago: 61_000, symbol: "TWO", token: "0x0000000000000000000000000000000000000004" });
+ const marks = only([first, second]);
+ assert.ok(marks.every((m) => m.showLabel.wide), "two launches a second apart both get names");
+ assert.notEqual(marks[0].side, marks[1].side, "one above the line, one below");
+ assert.ok(marks.every((m) => !m.showLabel.compact));
+});
+
+test("labels near the far edge give way rather than spill out of the field", () => {
+ const edge = swap({ ago: RIVER_WINDOW_MS - 5_000, usd: 500, symbol: "OLD" });
+ const [m] = only([edge, swap({ ago: 0, usd: 1 })]).filter((x) => x.label?.startsWith("OLD"));
+ assert.equal(m.showLabel.wide, false);
+});
diff --git a/app/src/lib/launchpad/river.ts b/app/src/lib/launchpad/river.ts
new file mode 100644
index 00000000..d25cd85c
--- /dev/null
+++ b/app/src/lib/launchpad/river.ts
@@ -0,0 +1,237 @@
+import type { FeedItem } from "./queries";
+
+/**
+ * The home page river: the last half hour of real launches and trades, newest at the right edge.
+ * Time runs along x and is drawn by CSS (every mark drifts at the same speed), so this module only
+ * decides what x cannot: which events are in the window, how high each one sits, how big it is,
+ * and which labels fit without colliding. Pure, so the layout is testable without a browser.
+ */
+export const RIVER_WINDOW_MS = 30 * 60_000;
+/** Gridlines, in minutes ago. */
+export const RIVER_TICKS = [5, 10, 15, 20, 25] as const;
+/** Upper bound on marks held at once. The live poll delivers the newest 24 every five seconds. */
+export const RIVER_MAX_ITEMS = 240;
+/** Field heights in px. The compact field (phones) scales marks by COMPACT_SCALE. */
+export const RIVER_HEIGHT = { wide: 220, compact: 150 } as const;
+export const COMPACT_SCALE = 0.75;
+/** A trade must be at least this many dollars to carry a label at rest; smaller ones label on hover. */
+export const LABEL_MIN_USD = { wide: 20, compact: 90 } as const;
+
+const LAUNCH_R = 11;
+const CLUSTER_MS = 12_000; // marks closer than this in the same lane stack instead of overlapping
+const MAX_STACK = 4;
+const LABEL_H = 15;
+const CHAR_W = 6.7; // 11px monospace
+const FUTURE_SKEW_MS = 60_000; // a server clock slightly ahead must not drop the newest trade
+
+export type RiverKind = "launch" | "buy" | "sell";
+/** A feed item plus when this client first saw it. `fresh` marks arrivals after the first paint (they ping). */
+export type RiverEntry = { item: FeedItem; seenAt: number; fresh: boolean };
+export type RiverMark = {
+ key: string;
+ item: FeedItem;
+ kind: RiverKind;
+ at: number;
+ /** Age at the moment this client first drew it; fixes the mark's CSS animation offset for its whole life. */
+ ageAtSeen: number;
+ fresh: boolean;
+ /** Vertical centre as a fraction of the field height. 0.5 is the line: buys above, sells below, launches on it. */
+ y: number;
+ /** Radius in px in the wide field. */
+ r: number;
+ /** Stacked marks fan out to the left by this many px so a burst never hides behind its first trade. */
+ nudge: number;
+ /** "WAIFU $54" for a priced trade, the symbol for a launch, null for an unpriced trade. */
+ label: string | null;
+ /** Launch labels sit above or below the line. */
+ side: "up" | "down";
+ /** Whether the label shows at rest in each field size. Hidden labels still show on hover. */
+ showLabel: { wide: boolean; compact: boolean };
+};
+
+/** One identity per feed event, shared by the river and its list view. */
+export function feedKey(item: FeedItem): string {
+ return `${item.chain}:${item.kind}:${item.tx_hash}:${item.token}${item.kind === "swap" ? `:${item.log_index}` : ""}`;
+}
+
+function atMs(item: FeedItem): number {
+ return new Date(item.at).getTime();
+}
+
+export function inRiverWindow(item: FeedItem, now: number): boolean {
+ const age = now - atMs(item);
+ return Number.isFinite(age) && age >= -FUTURE_SKEW_MS && age <= RIVER_WINDOW_MS;
+}
+
+function ordered(entries: RiverEntry[]): RiverEntry[] {
+ return entries.sort((a, b) => atMs(b.item) - atMs(a.item) || feedKey(a.item).localeCompare(feedKey(b.item))).slice(0, RIVER_MAX_ITEMS);
+}
+
+/** First paint: everything already in the window, none of it fresh. */
+export function initialRiver(items: readonly FeedItem[], now: number): RiverEntry[] {
+ const byKey = new Map();
+ for (const item of items) if (inRiverWindow(item, now)) byKey.set(feedKey(item), { item, seenAt: now, fresh: false });
+ return ordered([...byKey.values()]);
+}
+
+/**
+ * Fold a poll into the river. Known events keep when they were first seen (so they never jump) but take the
+ * newer payload (a price that arrived late); unknown ones are fresh. Anything older than the window drops out.
+ */
+export function mergeRiver(current: readonly RiverEntry[], incoming: readonly FeedItem[], now: number): RiverEntry[] {
+ const byKey = new Map();
+ for (const entry of current) if (inRiverWindow(entry.item, now)) byKey.set(feedKey(entry.item), entry);
+ for (const item of incoming) {
+ if (!inRiverWindow(item, now)) continue;
+ const key = feedKey(item);
+ const known = byKey.get(key);
+ byKey.set(key, known ? { ...known, item } : { item, seenAt: now, fresh: true });
+ }
+ return ordered([...byKey.values()]);
+}
+
+/** Drop marks that have left the window. Returns the same array when nothing changed. */
+export function pruneRiver(current: RiverEntry[], now: number): RiverEntry[] {
+ const kept = current.filter((entry) => inRiverWindow(entry.item, now));
+ return kept.length === current.length ? current : kept;
+}
+
+/** Dollar labels sized for a 11px mark: "$5.20", "$54", "$1.2K". */
+export function riverUsd(v: number): string {
+ if (!Number.isFinite(v) || v <= 0) return "$0";
+ if (v < 0.01) return "<$0.01";
+ if (v < 9.995) return `$${v.toFixed(2)}`;
+ if (v < 999.5) return `$${Math.round(v)}`;
+ // compact by hand: Intl's compact notation prints "$1.0K" or "$1K" depending on the runtime's ICU version
+ for (const [size, suffix] of [[1e9, "B"], [1e6, "M"], [1e3, "K"]] as const) {
+ if (v < size * 0.9995 && size !== 1e3) continue;
+ const x = Math.round((v / size) * 10) / 10;
+ return `$${Number.isInteger(x) ? x.toFixed(0) : x.toFixed(1)}${suffix}`;
+ }
+ return `$${Math.round(v)}`;
+}
+
+/**
+ * The seed is the newest `limit` events. When it came back full and its oldest event is still inside the window,
+ * older events in the window were cut off: until the window moves past that event, the river holds the latest
+ * events, not all of them. Returns the time from which coverage is complete, or null when it already is.
+ */
+export function riverCoverage(seed: readonly FeedItem[], limit: number, now: number): number | null {
+ if (seed.length < limit) return null;
+ const oldest = Math.min(...seed.map(atMs));
+ return Number.isFinite(oldest) && now - oldest < RIVER_WINDOW_MS ? oldest : null;
+}
+
+/** "18 trades and 2 launches in the last 30 minutes", or "Latest 100 trades" while the seed was cut off. */
+export function riverSummary(entries: readonly RiverEntry[], complete = true): string {
+ const launches = entries.filter((entry) => entry.item.kind === "launch").length;
+ const trades = entries.length - launches;
+ const part = (n: number, one: string, many: string) => `${n.toLocaleString("en-US")} ${n === 1 ? one : many}`;
+ if (!launches && !trades) return "Nothing in the last 30 minutes";
+ const parts = [trades ? part(trades, "trade", "trades") : null, launches ? part(launches, "launch", "launches") : null].filter(Boolean);
+ return complete ? `${parts.join(" and ")} in the last 30 minutes` : `Latest ${parts.join(" and ")}`;
+}
+
+function kindOf(item: FeedItem): RiverKind {
+ return item.kind === "launch" ? "launch" : item.is_buy ? "buy" : "sell";
+}
+
+/** log10 of the dollar size, clamped: $1 → 0.3, $100 → 2, $10K and up → 4. An unpriced trade draws as a small one. */
+function magnitude(item: FeedItem): number {
+ if (item.kind !== "swap") return 0;
+ if (item.usd === null || !Number.isFinite(item.usd)) return 1;
+ return Math.min(4, Math.max(0, Math.log10(1 + Math.max(0, item.usd))));
+}
+
+function hashSide(token: string): "up" | "down" {
+ let h = 0;
+ for (let i = 0; i < token.length; i++) h = (h * 31 + token.charCodeAt(i)) | 0;
+ return (h & 1) === 0 ? "up" : "down";
+}
+
+type Rect = { left: number; right: number; top: number; bottom: number };
+const overlaps = (a: Rect, b: Rect) => a.left < b.right && b.left < a.right && a.top < b.bottom && b.top < a.bottom;
+
+/**
+ * Lay out every mark. `width` is the field width in px for each size (measured on the client, estimated on the
+ * server); it only affects which labels fit, since relative x between two marks is width × Δt / window.
+ */
+export function layoutRiver(entries: readonly RiverEntry[], width: { wide: number; compact: number }): RiverMark[] {
+ const H = RIVER_HEIGHT.wide;
+ const mid = H / 2;
+ // stack bursts per lane, oldest first, so a mark's place never depends on what arrives after it
+ const lanes = new Map();
+ const stack = new Map();
+ for (const entry of [...entries].sort((a, b) => atMs(a.item) - atMs(b.item))) {
+ const kind = kindOf(entry.item);
+ const at = atMs(entry.item);
+ const prev = lanes.get(kind);
+ const j = prev && at - prev.at < CLUSTER_MS ? (prev.j + 1) % (MAX_STACK + 1) : 0;
+ lanes.set(kind, { at, j });
+ stack.set(feedKey(entry.item), j);
+ }
+
+ const marks: RiverMark[] = entries.map((entry) => {
+ const { item } = entry;
+ const key = feedKey(item);
+ const kind = kindOf(item);
+ const at = atMs(item);
+ const j = stack.get(key) ?? 0;
+ const ageAtSeen = Math.max(0, entry.seenAt - at);
+ if (kind === "launch") {
+ return { key, item, kind, at, ageAtSeen, fresh: entry.fresh, y: 0.5, r: LAUNCH_R, nudge: j * 8, label: item.symbol, side: hashSide(item.token), showLabel: { wide: false, compact: false } };
+ }
+ const mag = magnitude(item);
+ const r = 3 + mag * 3.2;
+ const off = Math.min(mid - r - 4, Math.max(r + 6, 16 + mag * 20 + j * 9));
+ const usd = item.kind === "swap" ? item.usd : null;
+ return {
+ key, item, kind, at, ageAtSeen, fresh: entry.fresh,
+ y: (kind === "buy" ? mid - off : mid + off) / H,
+ r, nudge: j * 7,
+ label: usd !== null && Number.isFinite(usd) ? `${item.symbol} ${riverUsd(usd)}` : null,
+ side: kind === "buy" ? "up" : "down",
+ showLabel: { wide: false, compact: false },
+ };
+ });
+
+ // labels: launches first (newest first), then trades by size; a label shows only where it collides with none shown
+ const newest = marks.reduce((max, m) => Math.max(max, m.at), 0);
+ const priority = [...marks].sort((a, b) =>
+ (a.kind === "launch" ? 0 : 1) - (b.kind === "launch" ? 0 : 1) ||
+ (a.kind === "launch" ? b.at - a.at : usdOf(b) - usdOf(a) || b.at - a.at));
+ for (const size of ["wide", "compact"] as const) {
+ const W = width[size];
+ const scale = size === "compact" ? COMPACT_SCALE : 1;
+ const h = RIVER_HEIGHT[size];
+ const placed: Rect[] = [];
+ for (const m of priority) {
+ if (!m.label) continue;
+ if (m.kind !== "launch" && usdOf(m) < LABEL_MIN_USD[size]) continue;
+ if (m.kind === "launch" && size === "compact") continue; // a phone shows the mark and the lock; the name is one tap away
+ const x = W * (1 - (newest - m.at) / RIVER_WINDOW_MS) - m.nudge;
+ const y = m.y * h;
+ const r = m.r * scale;
+ const w = m.label.length * CHAR_W + 8;
+ const candidates: { rect: Rect; side: "up" | "down" }[] = m.kind === "launch"
+ ? [m.side, m.side === "up" ? "down" as const : "up" as const].map((side) => ({
+ side,
+ rect: side === "up"
+ ? { left: x - w / 2, right: x + w / 2, top: y - r - 8 - LABEL_H, bottom: y - r - 8 }
+ : { left: x - w / 2, right: x + w / 2, top: y + r + 10, bottom: y + r + 10 + LABEL_H },
+ }))
+ // a trade's label sits to the left of its dot, pointing back in time, so the newest one is never cut off
+ : [{ side: m.side, rect: { left: x - r - 5 - w, right: x - r - 5, top: y - LABEL_H / 2, bottom: y + LABEL_H / 2 } }];
+ const fit = candidates.find((c) => c.rect.left >= 0 && c.rect.top >= 0 && c.rect.bottom <= h && !placed.some((p) => overlaps(p, c.rect)));
+ if (!fit) continue;
+ placed.push(fit.rect);
+ m.showLabel[size] = true;
+ if (size === "wide") m.side = fit.side;
+ }
+ }
+ return marks;
+}
+
+function usdOf(m: RiverMark): number {
+ return m.item.kind === "swap" && m.item.usd !== null && Number.isFinite(m.item.usd) ? m.item.usd : 0;
+}
diff --git a/app/src/lib/launchpad/watchlist.test.ts b/app/src/lib/launchpad/watchlist.test.ts
new file mode 100644
index 00000000..28f3f518
--- /dev/null
+++ b/app/src/lib/launchpad/watchlist.test.ts
@@ -0,0 +1,323 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { CHAIN_KEYS, type ChainKey } from "../chainKeys.ts";
+import {
+ createWatchlistStore, markWatchlistSeen, parseWatchlist, serializeWatchlist, toggleWatchlistEntry,
+ WATCHLIST_LIMIT, WATCHLIST_SERVER_SNAPSHOT, WATCHLIST_STORAGE_KEY, watchlistKey,
+ type WatchlistEntry, type WatchlistIdentity,
+} from "./watchlist.ts";
+
+const NOW = 1_800_000_000_000;
+const token = (n = 1, chain: ChainKey = "base"): WatchlistIdentity => ({
+ chain, token: `0x${n.toString(16).padStart(40, "0")}`, name: `Token ${n}`, symbol: `T${n}`,
+});
+const entry = (n = 1): WatchlistEntry => ({ ...token(n), addedAt: NOW - 1_000, seenAt: null, holders: null });
+const payload = (entries: unknown[], version = 1) => JSON.stringify({ version, entries });
+
+test("normalizes identity and keeps the same address on different chains distinct", () => {
+ const base = { ...entry(10), token: token(10).token.toUpperCase(), name: " Token A ", symbol: " A " };
+ const robinhood = { ...base, chain: "robinhood" };
+ const result = parseWatchlist(payload([base, robinhood]), NOW);
+ assert.equal(result.length, 2);
+ assert.equal(result[0].token, token(10).token);
+ assert.equal(result[0].name, "Token A");
+ assert.equal(result[0].symbol, "A");
+ assert.notEqual(watchlistKey(result[0]), watchlistKey(result[1]));
+});
+
+test("the v1 store retains existing entries when Arc is added, reviewed and reloaded", () => {
+ assert.equal(WATCHLIST_STORAGE_KEY, "openlaunch:watchlist:v1");
+ const storage = memory();
+ const oldEntries = [
+ { ...entry(), seenAt: NOW - 500, holders: 7 },
+ { ...entry(), chain: "robinhood" as const, seenAt: NOW - 100, holders: 3 },
+ ];
+ storage.write(payload(oldEntries));
+ const store = createWatchlistStore(storage, () => NOW);
+ store.refresh();
+ assert.deepEqual(store.getSnapshot().entries, oldEntries);
+ assert.equal(store.toggle(token(1, "arc")), "added");
+ store.markSeen([{ ...token(1, "arc"), seenAt: NOW, holders: 0 }]);
+ const reloaded = createWatchlistStore(storage, () => NOW);
+ reloaded.refresh();
+ assert.equal(JSON.parse(storage.read()!).version, 1);
+ assert.deepEqual(reloaded.getSnapshot().entries, [
+ { ...token(1, "arc"), addedAt: NOW, seenAt: NOW, holders: 0 }, ...oldEntries,
+ ]);
+ assert.equal(new Set(reloaded.getSnapshot().savedKeys).size, CHAIN_KEYS.length);
+ assert.equal(reloaded.toggle(token(1, "arc")), "removed");
+ assert.deepEqual(reloaded.getSnapshot().entries, oldEntries);
+});
+
+test("watchlist identity validation stays aligned with the shared chain registry", () => {
+ const entries = CHAIN_KEYS.map((chain) => ({ ...entry(), ...token(1, chain) }));
+ assert.deepEqual(parseWatchlist(payload(entries), NOW), entries);
+ assert.deepEqual(markWatchlistSeen(entries, CHAIN_KEYS.map((chain) => ({ ...token(1, chain), seenAt: NOW, holders: 1 })), NOW),
+ entries.map((saved) => ({ ...saved, seenAt: NOW, holders: 1 })));
+});
+
+test("corrupt and unsupported payloads are empty rather than throwing", () => {
+ for (const raw of [null, "", "{", "null", "[]", payload([entry()], 2), JSON.stringify({ version: 1 }), "x".repeat(128_001)]) {
+ assert.deepEqual(parseWatchlist(raw, NOW), []);
+ }
+});
+
+test("rejects malformed identities, timestamps and holder counts independently", () => {
+ const bad = [
+ null, [], {}, { ...entry(), chain: "ethereum" }, { ...entry(), token: "0x1" },
+ { ...entry(), token: `0x${"z".repeat(40)}` }, { ...entry(), name: " " }, { ...entry(), symbol: 1 },
+ { ...entry(), addedAt: "123" }, { ...entry(), addedAt: -1 }, { ...entry(), addedAt: 1.5 },
+ { ...entry(), seenAt: NOW + 300_001 }, { ...entry(), holders: -1 }, { ...entry(), holders: 1.5 },
+ { ...entry(), holders: Number.MAX_SAFE_INTEGER + 1 },
+ ];
+ assert.deepEqual(parseWatchlist(payload([...bad, entry(2)]), NOW), [entry(2)]);
+});
+
+test("preserves timestamps within clock-skew tolerance and rejects far-future timestamps", () => {
+ const result = parseWatchlist(payload([
+ { ...entry(), addedAt: NOW + 1, seenAt: NOW + 300_000, holders: 0 },
+ { ...entry(2), addedAt: NOW + 300_001 },
+ ]), NOW);
+ assert.deepEqual(result, [{ ...entry(), addedAt: NOW + 1, seenAt: NOW + 300_000, holders: 0 }]);
+});
+
+test("a server clock one minute ahead keeps the reviewed cutoff through reload", () => {
+ const storage = memory();
+ const store = createWatchlistStore(storage, () => NOW);
+ const serverAt = NOW + 60_000;
+ store.toggle(token());
+ store.markSeen([{ ...token(), seenAt: serverAt, holders: 0 }]);
+ assert.equal(store.getSnapshot().entries[0].seenAt, serverAt);
+ const reloaded = createWatchlistStore(storage, () => NOW);
+ reloaded.refresh();
+ const cutoff = reloaded.getSnapshot().entries[0].seenAt!;
+ assert.equal(cutoff, serverAt);
+ // API counts strictly after the cutoff: none of this reviewed minute may reappear.
+ const reviewedTrades = [NOW + 1, NOW + 30_000, serverAt];
+ assert.deepEqual(reviewedTrades.filter((time) => time > cutoff), []);
+});
+
+test("deduplicates a token without rolling its baseline back", () => {
+ const fresh = { ...entry(), seenAt: NOW - 10, holders: 2 };
+ const old = { ...entry(), addedAt: NOW - 2_000, seenAt: NOW - 50, holders: 9 };
+ for (const entries of [[fresh, old], [old, fresh]]) {
+ assert.deepEqual(parseWatchlist(payload(entries), NOW), [{ ...fresh, addedAt: old.addedAt }]);
+ }
+});
+
+test("limits stored entries to 50, caps metadata and clears holders with no baseline", () => {
+ const entries = Array.from({ length: 55 }, (_, i) => entry(i));
+ const result = parseWatchlist(payload(entries), NOW);
+ assert.equal(result.length, WATCHLIST_LIMIT);
+ const [bounded] = parseWatchlist(payload([{ ...entry(), name: "a".repeat(200), symbol: "b".repeat(60), holders: 100 }]), NOW);
+ assert.equal(bounded.name.length, 128);
+ assert.equal(bounded.symbol.length, 32);
+ assert.equal(bounded.holders, null);
+ assert.deepEqual(parseWatchlist(serializeWatchlist(result), NOW), result);
+});
+
+test("toggle adds unreviewed entries, removes by normalized address, and never silently evicts at the cap", () => {
+ const added = toggleWatchlistEntry([], token(10), NOW);
+ assert.equal(added.result, "added");
+ assert.deepEqual(added.entries, [{ ...token(10), addedAt: NOW, seenAt: null, holders: null }]);
+ assert.deepEqual(toggleWatchlistEntry(added.entries, { ...token(10), token: token(10).token.toUpperCase() }, NOW), { entries: [], result: "removed" });
+ const full = Array.from({ length: 50 }, (_, i) => entry(i));
+ assert.deepEqual(toggleWatchlistEntry(full, token(100), NOW), { entries: full, result: "limit" });
+ assert.equal(toggleWatchlistEntry(full, token(1), NOW).result, "removed");
+ assert.equal(toggleWatchlistEntry([], { ...token(), token: "invalid" }, NOW).result, "invalid");
+});
+
+test("mark seen updates only supplied saved tokens and distinguishes null from zero", () => {
+ const entries = [entry(1), entry(2), entry(3)];
+ const next = markWatchlistSeen(entries, [
+ { ...token(1), seenAt: NOW, holders: 0 },
+ { ...token(2), seenAt: NOW, holders: null },
+ { ...token(99), seenAt: NOW, holders: 10 },
+ ], NOW);
+ assert.deepEqual(next, [{ ...entries[0], seenAt: NOW, holders: 0 }, { ...entries[1], seenAt: NOW, holders: null }, entries[2]]);
+ assert.equal(next[2], entries[2]);
+ assert.deepEqual(entries, [entry(1), entry(2), entry(3)]);
+});
+
+test("older or repeated responses cannot overwrite a reviewed baseline", () => {
+ const entries = [{ ...entry(), seenAt: NOW, holders: 12 }];
+ for (const seenAt of [NOW - 1, NOW, null, NOW + 300_001]) {
+ assert.equal(markWatchlistSeen(entries, [{ ...token(), seenAt, holders: 0 }], NOW), entries);
+ }
+});
+
+test("duplicate observations use the newest valid observation", () => {
+ const next = markWatchlistSeen([entry()], [
+ { ...token(), seenAt: NOW, holders: 0 }, { ...token(), seenAt: NOW - 1, holders: 1 },
+ { ...token(), seenAt: NOW + 300_001, holders: 2 },
+ ], NOW);
+ assert.deepEqual(next, [{ ...entry(), seenAt: NOW, holders: 0 }]);
+});
+
+function memory() {
+ let raw: string | null = null;
+ return { read: () => raw, write: (value: string) => { raw = value; } };
+}
+
+test("store is SSR-safe and snapshots stay referentially stable until a real update", () => {
+ let reads = 0;
+ const store = createWatchlistStore({ read: () => { reads++; return null; }, write: () => {} }, () => NOW);
+ assert.equal(store.getSnapshot(), WATCHLIST_SERVER_SNAPSHOT);
+ assert.equal(reads, 0);
+ let notifications = 0;
+ const unsubscribe = store.subscribe(() => { notifications++; });
+ store.refresh();
+ const hydrated = store.getSnapshot();
+ assert.deepEqual(hydrated, { entries: [], savedKeys: [], ready: true, storageError: false });
+ store.refresh();
+ assert.equal(store.getSnapshot(), hydrated);
+ assert.equal(notifications, 1);
+ unsubscribe();
+ store.toggle(token());
+ assert.equal(notifications, 1);
+});
+
+test("same-store subscribers observe changes and reload restores the saved list", () => {
+ const storage = memory();
+ const store = createWatchlistStore(storage, () => NOW);
+ let first = 0, second = 0;
+ store.subscribe(() => { first++; });
+ store.subscribe(() => { second++; });
+ store.toggle(token());
+ store.markSeen([{ ...token(), seenAt: NOW, holders: 0 }]);
+ assert.ok(first > 0);
+ assert.equal(first, second);
+ const reloaded = createWatchlistStore(storage, () => NOW);
+ reloaded.refresh();
+ assert.deepEqual(reloaded.getSnapshot(), store.getSnapshot());
+});
+
+test("stale tabs read latest storage before mutating and refresh handles removals", () => {
+ const storage = memory();
+ const a = createWatchlistStore(storage, () => NOW);
+ const b = createWatchlistStore(storage, () => NOW);
+ a.refresh(); b.refresh();
+ a.toggle(token(1));
+ b.toggle(token(2));
+ assert.equal(b.getSnapshot().entries.length, 2);
+ a.refresh();
+ assert.deepEqual(a.getSnapshot(), b.getSnapshot());
+ b.toggle(token(1));
+ a.refresh();
+ assert.deepEqual(a.getSnapshot().entries.map(watchlistKey), [watchlistKey(token(2))]);
+});
+
+test("storage read and write failures leave an ephemeral usable list", () => {
+ const store = createWatchlistStore({ read: () => { throw new Error("denied"); }, write: () => { throw new Error("denied"); } }, () => NOW);
+ store.refresh();
+ assert.deepEqual(store.getSnapshot(), { entries: [], savedKeys: [], ready: true, storageError: true });
+ store.toggle(token());
+ store.markSeen([{ ...token(), seenAt: NOW, holders: 0 }]);
+ assert.equal(store.getSnapshot().entries[0].holders, 0);
+ store.refresh();
+ assert.equal(store.getSnapshot().entries.length, 1);
+ store.toggle(token());
+ assert.equal(store.getSnapshot().entries.length, 0);
+});
+
+test("quota failures preserve pending additions and removals across refresh and recover on next write", () => {
+ const disk = memory();
+ disk.write(serializeWatchlist([entry(1)]));
+ let blocked = true;
+ const store = createWatchlistStore({ read: disk.read, write: (raw) => { if (blocked) throw new Error("quota"); disk.write(raw); } }, () => NOW);
+ store.toggle(token(1)); // Local removal must not reappear from the old disk snapshot.
+ store.toggle(token(2));
+ store.refresh();
+ assert.deepEqual(store.getSnapshot().entries.map(watchlistKey), [watchlistKey(token(2))]);
+ disk.write(serializeWatchlist([entry(1), entry(3)])); // Another tab's unrelated addition survives.
+ store.refresh();
+ assert.deepEqual(new Set(store.getSnapshot().entries.map(watchlistKey)), new Set([watchlistKey(token(2)), watchlistKey(token(3))]));
+ assert.equal(store.getSnapshot().storageError, true);
+ blocked = false;
+ store.markSeen([{ ...token(2), seenAt: NOW, holders: 0 }]);
+ assert.equal(store.getSnapshot().storageError, false);
+ assert.deepEqual(parseWatchlist(disk.read(), NOW), store.getSnapshot().entries);
+});
+
+test("a stale tab cannot restore a removed token by marking it reviewed", () => {
+ const storage = memory();
+ const a = createWatchlistStore(storage, () => NOW);
+ const b = createWatchlistStore(storage, () => NOW);
+ a.toggle(token()); b.refresh(); a.toggle(token());
+ b.markSeen([{ ...token(), seenAt: NOW, holders: 10 }]);
+ assert.deepEqual(b.getSnapshot().entries, []);
+});
+
+test("quota conflicts at capacity keep the local addition and never persist a clipped remote list", () => {
+ const disk = memory();
+ const original = Array.from({ length: 49 }, (_, i) => entry(i));
+ disk.write(serializeWatchlist(original));
+ let blocked = true;
+ let writes = 0;
+ const store = createWatchlistStore({ read: disk.read, write: (raw) => {
+ writes++;
+ if (blocked) throw new Error("quota");
+ disk.write(raw);
+ } }, () => NOW);
+ store.toggle(token(49)); // Local 50th addition is pending.
+ const remote = serializeWatchlist([...original, entry(50)]);
+ disk.write(remote); // A different tab successfully adds a different 50th token.
+ store.refresh();
+ assert.equal(store.getSnapshot().entries.length, 50);
+ assert.ok(store.getSnapshot().entries.some((item) => watchlistKey(item) === watchlistKey(token(49))));
+
+ blocked = false;
+ const writesBeforeReview = writes;
+ store.markSeen([{ ...token(49), seenAt: NOW, holders: 0 }]);
+ assert.equal(writes, writesBeforeReview, "review must not write while merged preferences exceed capacity");
+ assert.equal(disk.read(), remote, "remote preferences remain untouched");
+ assert.equal(store.getSnapshot().storageError, true);
+ store.refresh();
+ assert.ok(store.getSnapshot().entries.some((item) => watchlistKey(item) === watchlistKey(token(49))));
+
+ store.toggle(token(1)); // An explicit removal makes room for both pending and remote additions.
+ const persisted = parseWatchlist(disk.read(), NOW);
+ assert.equal(persisted.length, 50);
+ assert.ok(persisted.some((item) => item.token === token(49).token));
+ assert.ok(persisted.some((item) => item.token === token(50).token));
+ assert.ok(!persisted.some((item) => item.token === token(1).token));
+ assert.equal(store.getSnapshot().storageError, false);
+});
+
+test("membership remains accurate for a persisted token hidden by the conflict display cap", () => {
+ const disk = memory();
+ const original = Array.from({ length: 49 }, (_, i) => entry(i));
+ disk.write(serializeWatchlist(original));
+ let blocked = true;
+ const store = createWatchlistStore({ read: disk.read, write: (raw) => {
+ if (blocked) throw new Error("quota");
+ disk.write(raw);
+ } }, () => NOW);
+ store.toggle(token(49));
+ disk.write(serializeWatchlist([...original, entry(50)]));
+ store.refresh();
+ const snapshot = store.getSnapshot();
+ assert.equal(snapshot.entries.length, 50);
+ assert.ok(!snapshot.entries.some((item) => item.token === token(50).token));
+ assert.ok(snapshot.savedKeys.includes(watchlistKey(token(50))), "the star must still present the Remove action");
+ assert.equal(snapshot.savedKeys.length, 51);
+ store.refresh();
+ assert.equal(store.getSnapshot(), snapshot, "unchanged membership keeps a stable external-store snapshot");
+
+ let notifications = 0;
+ store.subscribe(() => { notifications++; });
+ disk.write(serializeWatchlist([...original, entry(51)]));
+ store.refresh();
+ assert.deepEqual(store.getSnapshot().entries, snapshot.entries, "the capped display rows have not changed");
+ assert.ok(!store.getSnapshot().savedKeys.includes(watchlistKey(token(50))));
+ assert.ok(store.getSnapshot().savedKeys.includes(watchlistKey(token(51))));
+ assert.equal(notifications, 1, "membership-only changes must notify stars outside the watchlist panel");
+ disk.write(serializeWatchlist([...original, entry(50)]));
+ store.refresh();
+
+ blocked = false;
+ assert.equal(store.toggle(token(50)), "removed");
+ assert.ok(!store.getSnapshot().savedKeys.includes(watchlistKey(token(50))));
+ assert.ok(store.getSnapshot().savedKeys.includes(watchlistKey(token(49))));
+ assert.ok(!parseWatchlist(disk.read(), NOW).some((item) => item.token === token(50).token));
+});
diff --git a/app/src/lib/launchpad/watchlist.ts b/app/src/lib/launchpad/watchlist.ts
new file mode 100644
index 00000000..c708dc9d
--- /dev/null
+++ b/app/src/lib/launchpad/watchlist.ts
@@ -0,0 +1,189 @@
+import { isChainKey, type ChainKey } from "../chainKeys.ts";
+
+/** Browser-local preferences only. No wallet, trading, or server-side identity. */
+export const WATCHLIST_STORAGE_KEY = "openlaunch:watchlist:v1";
+export const WATCHLIST_LIMIT = 50;
+const CLOCK_SKEW_MS = 5 * 60 * 1_000;
+
+export type WatchlistIdentity = { chain: ChainKey; token: string; name: string; symbol: string };
+export type WatchlistEntry = WatchlistIdentity & { addedAt: number; seenAt: number | null; holders: number | null };
+export type WatchlistSeen = Pick;
+export type WatchlistSnapshot = { entries: WatchlistEntry[]; savedKeys: string[]; ready: boolean; storageError: boolean };
+export type WatchlistToggleResult = "added" | "removed" | "limit" | "invalid";
+
+export const WATCHLIST_SERVER_SNAPSHOT: WatchlistSnapshot = { entries: [], savedKeys: [], ready: false, storageError: false };
+
+export function watchlistKey(identity: { chain: string; token: string }): string {
+ return `${identity.chain}:${identity.token.toLowerCase()}`;
+}
+
+function record(value: unknown): value is Record {
+ return value !== null && typeof value === "object" && !Array.isArray(value);
+}
+
+function identity(value: unknown): WatchlistIdentity | null {
+ if (!record(value) || !isChainKey(value.chain) ||
+ typeof value.token !== "string" || !/^0x[\da-f]{40}$/i.test(value.token) ||
+ typeof value.name !== "string" || !value.name.trim() ||
+ typeof value.symbol !== "string" || !value.symbol.trim()) return null;
+ return { chain: value.chain, token: value.token.toLowerCase(), name: value.name.trim().slice(0, 128), symbol: value.symbol.trim().slice(0, 32) };
+}
+
+function validTime(value: unknown, now: number): value is number {
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= now + CLOCK_SKEW_MS;
+}
+
+function validHolders(value: unknown): value is number | null {
+ return value === null || (typeof value === "number" && Number.isSafeInteger(value) && value >= 0);
+}
+
+/** Reject malformed entries; tolerate small clock differences without rewriting server checkpoints. */
+export function parseWatchlist(raw: string | null, now = Date.now()): WatchlistEntry[] {
+ if (!raw || raw.length > 128_000) return [];
+ try {
+ const payload: unknown = JSON.parse(raw);
+ if (!record(payload) || payload.version !== 1 || !Array.isArray(payload.entries)) return [];
+ const entries = new Map();
+ for (const value of payload.entries) {
+ const token = identity(value);
+ if (!token || !record(value) || !validTime(value.addedAt, now) ||
+ (value.seenAt !== null && !validTime(value.seenAt, now)) || !validHolders(value.holders)) continue;
+ const entry: WatchlistEntry = {
+ ...token, addedAt: value.addedAt,
+ seenAt: value.seenAt as number | null,
+ holders: value.seenAt === null ? null : value.holders,
+ };
+ const key = watchlistKey(entry);
+ const existing = entries.get(key);
+ if (existing) {
+ // A duplicate must never roll a reviewed snapshot backward.
+ const latest = (entry.seenAt ?? -1) > (existing.seenAt ?? -1) ? entry : existing;
+ entries.set(key, { ...latest, addedAt: Math.min(entry.addedAt, existing.addedAt) });
+ } else if (entries.size < WATCHLIST_LIMIT) entries.set(key, entry);
+ }
+ return [...entries.values()];
+ } catch {
+ return [];
+ }
+}
+
+export function serializeWatchlist(entries: readonly WatchlistEntry[]): string {
+ return JSON.stringify({ version: 1, entries });
+}
+
+export function toggleWatchlistEntry(entries: WatchlistEntry[], value: WatchlistIdentity, now = Date.now()): { entries: WatchlistEntry[]; result: WatchlistToggleResult } {
+ const token = identity(value);
+ if (!token) return { entries, result: "invalid" };
+ const key = watchlistKey(token);
+ if (entries.some((entry) => watchlistKey(entry) === key)) {
+ return { entries: entries.filter((entry) => watchlistKey(entry) !== key), result: "removed" };
+ }
+ if (entries.length >= WATCHLIST_LIMIT) return { entries, result: "limit" };
+ return { entries: [{ ...token, addedAt: now, seenAt: null, holders: null }, ...entries], result: "added" };
+}
+
+/** Only explicit, successful observations advance a baseline. Null holders mean unavailable, not zero. */
+export function markWatchlistSeen(entries: WatchlistEntry[], observations: readonly WatchlistSeen[], now = Date.now()): WatchlistEntry[] {
+ const updates = new Map();
+ for (const observation of observations) {
+ if (!record(observation) || !isChainKey(observation.chain) ||
+ typeof observation.token !== "string" || !/^0x[\da-f]{40}$/i.test(observation.token) ||
+ !validTime(observation.seenAt, now) || !validHolders(observation.holders)) continue;
+ const key = watchlistKey(observation);
+ const previous = updates.get(key);
+ if (!previous || observation.seenAt > (previous.seenAt ?? -1)) {
+ updates.set(key, observation);
+ }
+ }
+ let changed = false;
+ const next = entries.map((entry) => {
+ const update = updates.get(watchlistKey(entry));
+ if (!update || (update.seenAt ?? -1) <= (entry.seenAt ?? -1)) return entry;
+ changed = true;
+ return { ...entry, seenAt: update.seenAt, holders: update.holders };
+ });
+ return changed ? next : entries;
+}
+
+/** Injectable storage keeps quota failures and stale-tab merges testable without a browser. */
+export function createWatchlistStore(storage: { read: () => string | null; write: (value: string) => void }, now = Date.now) {
+ let snapshot = WATCHLIST_SERVER_SNAPSHOT;
+ // Keep a full conflict set internally even when the UI is capped at 50. Otherwise a later
+ // review could persist the clipped list and silently delete another tab's saved token.
+ let workingEntries: WatchlistEntry[] = [];
+ const listeners = new Set<() => void>();
+ // Retain explicit local edits after a failed write while still accepting other tabs' unrelated edits.
+ const pending = new Map();
+
+ function publish(entries: WatchlistEntry[], storageError: boolean) {
+ workingEntries = entries;
+ // Membership is not a display concern: a remote token hidden by the conflict cap must
+ // still render as watched everywhere, and its action must remain "Remove", not "Save".
+ const savedKeys = entries.map(watchlistKey);
+ const visible = entries.length > WATCHLIST_LIMIT
+ ? [...entries].sort((a, b) => Number(pending.has(watchlistKey(b))) - Number(pending.has(watchlistKey(a)))).slice(0, WATCHLIST_LIMIT)
+ : entries;
+ if (snapshot.ready && snapshot.storageError === storageError && serializeWatchlist(snapshot.entries) === serializeWatchlist(visible) && JSON.stringify(snapshot.savedKeys) === JSON.stringify(savedKeys)) return;
+ snapshot = { entries: visible, savedKeys, ready: true, storageError };
+ for (const listener of listeners) listener();
+ }
+
+ function refresh() {
+ try {
+ const latest = parseWatchlist(storage.read(), now());
+ const merged = new Map(latest.map((entry) => [watchlistKey(entry), entry]));
+ for (const [key, local] of pending) {
+ const remote = merged.get(key);
+ if (local === null) merged.delete(key);
+ else if (!remote || (local.seenAt ?? -1) >= (remote.seenAt ?? -1)) merged.set(key, local);
+ }
+ publish([...merged.values()], pending.size > 0);
+ } catch {
+ publish(workingEntries, true);
+ }
+ }
+
+ function save(entries: WatchlistEntry[]) {
+ if (entries.length > WATCHLIST_LIMIT) {
+ // A failed local save plus another tab's addition can exceed the limit. Preserve both
+ // intents in memory and leave disk untouched until an explicit removal resolves it.
+ publish(entries, true);
+ return;
+ }
+ try {
+ storage.write(serializeWatchlist(entries));
+ pending.clear();
+ publish(entries, false);
+ } catch {
+ publish(entries, true);
+ }
+ }
+
+ return {
+ getSnapshot: () => snapshot,
+ subscribe(listener: () => void) {
+ listeners.add(listener);
+ return () => { listeners.delete(listener); };
+ },
+ refresh,
+ toggle(value: WatchlistIdentity): WatchlistToggleResult {
+ refresh(); // Read latest immediately before applying a local intent; localStorage has no atomic CAS.
+ const next = toggleWatchlistEntry(workingEntries, value, now());
+ if (next.result === "added" || next.result === "removed") {
+ const key = watchlistKey(value);
+ pending.set(key, next.entries.find((entry) => watchlistKey(entry) === key) ?? null);
+ save(next.entries);
+ }
+ return next.result;
+ },
+ markSeen(observations: readonly WatchlistSeen[]) {
+ refresh();
+ const next = markWatchlistSeen(workingEntries, observations, now());
+ if (next === workingEntries) return;
+ for (let i = 0; i < next.length; i++) {
+ if (next[i] !== workingEntries[i]) pending.set(watchlistKey(next[i]), next[i]);
+ }
+ save(next);
+ },
+ };
+}
diff --git a/app/src/lib/launchpad/watchlistData.test.ts b/app/src/lib/launchpad/watchlistData.test.ts
new file mode 100644
index 00000000..9c2094c2
--- /dev/null
+++ b/app/src/lib/launchpad/watchlistData.test.ts
@@ -0,0 +1,256 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import ts from "typescript";
+import type { WatchlistData, WatchlistRequestItem } from "./watchlistData";
+import { CHAIN_IDS, isChainKey, type ChainKey } from "../chainKeys.ts";
+
+const source = readFileSync(new URL("./watchlistData.ts", import.meta.url), "utf8");
+const route = readFileSync(new URL("../../app/api/launch/watchlist/route.ts", import.meta.url), "utf8");
+const queries = readFileSync(new URL("./queries.ts", import.meta.url), "utf8");
+const NOW = 1_800_000_000_000;
+const TOKEN = `0x${"ab".repeat(20)}`;
+const OTHER = `0x${"cd".repeat(20)}`;
+
+function compile(source: string, imports: Record, date = Date) {
+ const output = ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText;
+ const exported: Record = {};
+ new Function("require", "exports", "Date", output)((id: string) => {
+ assert.ok(Object.hasOwn(imports, id), `unexpected import: ${id}`);
+ return imports[id];
+ }, exported, date);
+ return exported;
+}
+
+function isolatedData(configured = true) {
+ const calls = { launch: [] as unknown[][], sql: [] as { sql: string; values: unknown[] }[] };
+ const launches = [
+ { launch: { chain: "base", token: TOKEN, name: "Base token" }, holders: 0 },
+ { launch: { chain: "robinhood", token: TOKEN, name: "Robinhood token" }, holders: null },
+ { launch: { chain: "arc", token: TOKEN, name: "Arc token" }, holders: 4 },
+ ];
+ const counts = [
+ { chain_id: 8453, token: TOKEN, trades: "0", creator_posts: "2" },
+ { chain_id: 4663, token: TOKEN, trades: "8", creator_posts: "0" },
+ { chain_id: 5042, token: TOKEN, trades: "3", creator_posts: "1" },
+ ];
+ const db = Object.assign(async (parts: TemplateStringsArray, ...values: unknown[]) => {
+ calls.sql.push({ sql: parts.join("?"), values });
+ return counts;
+ }, { json: (value: unknown) => value });
+ const imported = compile(source, {
+ "server-only": {},
+ "@/lib/db": { maybeDb: () => configured ? db : null },
+ "@/lib/chainPublic": { chainIdOf: (chain: ChainKey) => CHAIN_IDS[chain], isChainKey },
+ "./queries": { getLaunchesByRefs: async (...args: unknown[]) => { calls.launch.push(args); return launches; } },
+ });
+ const api = imported as unknown as {
+ WATCHLIST_BODY_MAX_BYTES: number;
+ WATCHLIST_WINDOW_MS: number;
+ WatchlistInputError: new (message: string, status?: number) => Error & { status: number };
+ parseWatchlistRequest: (value: unknown, at: number) => WatchlistRequestItem[];
+ readWatchlistBody: (req: Request) => Promise;
+ getWatchlistData: (items: WatchlistRequestItem[], at: number, usd: number | null) => Promise;
+ };
+ return { ...api, calls, launches, counts };
+}
+
+test("watchlist input normalizes addresses, deduplicates within a chain, and preserves cross-chain identity", () => {
+ const { parseWatchlistRequest } = isolatedData();
+ assert.deepEqual(parseWatchlistRequest({ items: [
+ { chain: "base", token: TOKEN.toUpperCase(), since: NOW - 1 },
+ { chain: "base", token: TOKEN, since: NOW - 50 },
+ { chain: "robinhood", token: TOKEN },
+ ] }, NOW), [
+ { chain: "base", token: TOKEN, since: NOW - 1 },
+ { chain: "robinhood", token: TOKEN, since: null },
+ ]);
+});
+
+test("Arc snapshots use their own chain ID and do not inherit another chain's activity", async () => {
+ const { getWatchlistData, calls } = isolatedData();
+ const items: WatchlistRequestItem[] = [{ chain: "arc", token: TOKEN, since: NOW - 500 }];
+ const response = await getWatchlistData(items, NOW, 3_000);
+ assert.equal(response.items[0].launch?.chain, "arc");
+ assert.equal(response.items[0].holders, 4);
+ assert.equal(response.items[0].trades, 3);
+ assert.equal(response.items[0].creatorPosts, 1);
+ assert.deepEqual(calls.launch, [[items, 3_000]]);
+ assert.deepEqual(calls.sql[0].values, [[{ chain_id: 5042, token: TOKEN, since: new Date(NOW - 500).toISOString() }], NOW / 1_000, NOW / 1_000]);
+});
+
+test("watchlist rejects malformed, oversized, and future-dated input before reads", async () => {
+ const { parseWatchlistRequest, getWatchlistData, calls } = isolatedData();
+ for (const body of [null, [], {}, { items: "no" }, { items: [null] }, { items: [{ chain: "ethereum", token: TOKEN }] }, { items: [{ chain: "base", token: "0x123" }] }]) {
+ assert.throws(() => parseWatchlistRequest(body, NOW));
+ }
+ for (const since of [NaN, Infinity, -Infinity, -1, 3.1, "1800000000", NOW + 300_001, Number.MAX_SAFE_INTEGER + 1]) {
+ assert.throws(() => parseWatchlistRequest({ items: [{ chain: "base", token: TOKEN, since }] }, NOW));
+ }
+ const ref = { chain: "base" as const, token: TOKEN, since: null };
+ assert.equal(parseWatchlistRequest({ items: Array(50).fill(ref) }, NOW).length, 1);
+ assert.throws(() => parseWatchlistRequest({ items: Array(51).fill(ref) }, NOW));
+ await assert.rejects(getWatchlistData([{ ...ref, since: NOW + 300_001 }], NOW, null));
+ assert.deepEqual(calls, { launch: [], sql: [] });
+});
+
+test("small device clock skew clamps to the server cutoff without creating a future window", async () => {
+ const { parseWatchlistRequest, getWatchlistData, calls } = isolatedData();
+ for (const since of [NOW + 1, NOW + 300_000]) {
+ assert.deepEqual(parseWatchlistRequest({ items: [{ chain: "base", token: TOKEN, since }] }, NOW), [{ chain: "base", token: TOKEN, since: NOW }]);
+ }
+ const data = await getWatchlistData([{ chain: "base", token: TOKEN, since: NOW + 300_000 }], NOW, null);
+ assert.equal(data.items[0].since, NOW);
+ assert.equal(data.items[0].windowClamped, false);
+ assert.deepEqual(calls.sql[0].values, [[{ chain_id: 8453, token: TOKEN, since: new Date(NOW).toISOString() }], NOW / 1_000, NOW / 1_000]);
+});
+
+test("watchlist body reads enforce byte limits without trusting Content-Length", async () => {
+ const { readWatchlistBody, WATCHLIST_BODY_MAX_BYTES } = isolatedData();
+ const request = (body: string, extra: Record = {}) => new Request("https://example.test", { method: "POST", headers: { "content-type": "application/json", ...extra }, body });
+ assert.deepEqual(await readWatchlistBody(request('{"items":[]}')), { items: [] });
+ await assert.rejects(readWatchlistBody(request("{}", { "content-length": String(WATCHLIST_BODY_MAX_BYTES + 1) })), { status: 413 });
+ await assert.rejects(readWatchlistBody(request(JSON.stringify({ text: "界".repeat(6_000) }), { "content-length": "1" })), { status: 413 });
+ await assert.rejects(readWatchlistBody(request("not-json")), { status: 400 });
+ await assert.rejects(readWatchlistBody(request("{}", { "content-type": "text/plain" })), { status: 415 });
+ const bytes = new TextEncoder().encode('{"items":[]}');
+ const streamed = new Request("https://example.test", { method: "POST", headers: { "content-type": "application/json" }, duplex: "half", body: new ReadableStream({ start(controller) { controller.enqueue(bytes.slice(0, 5)); controller.enqueue(bytes.slice(5)); controller.close(); } }) } as RequestInit);
+ assert.deepEqual(await readWatchlistBody(streamed), { items: [] });
+});
+
+test("unconfigured indexes and unknown launches never masquerade as zero activity", async () => {
+ const ref = { chain: "base" as const, token: TOKEN, since: NOW - 1_000 };
+ const unavailable = isolatedData(false);
+ const response = await unavailable.getWatchlistData([ref], NOW, null);
+ assert.equal(response.indexed, false);
+ assert.deepEqual(response.items[0], { ...ref, windowClamped: false, launch: null, holders: null, trades: null, creatorPosts: null });
+ assert.deepEqual(unavailable.calls, { launch: [], sql: [] });
+ const { getWatchlistData } = isolatedData();
+ const missing = await getWatchlistData([{ ...ref, token: OTHER }], NOW, null);
+ assert.equal(missing.items[0].launch, null);
+ assert.equal(missing.items[0].holders, null);
+ assert.equal(missing.items[0].trades, null);
+ assert.equal(missing.items[0].creatorPosts, null);
+});
+
+test("batch snapshots retain request order, exact zero counts, first-visit nulls, and one cutoff", async () => {
+ const { getWatchlistData, calls } = isolatedData();
+ const items: WatchlistRequestItem[] = [
+ { chain: "robinhood", token: TOKEN, since: NOW - 500 },
+ { chain: "base", token: TOKEN, since: NOW - 1_000 },
+ { chain: "base", token: OTHER, since: null },
+ ];
+ const response = await getWatchlistData(items, NOW, 3_000);
+ assert.equal(response.at, NOW);
+ assert.equal(response.indexed, true);
+ assert.deepEqual(response.items.map(({ chain, token, trades, creatorPosts, holders }) => ({ chain, token, trades, creatorPosts, holders })), [
+ { chain: "robinhood", token: TOKEN, trades: 8, creatorPosts: 0, holders: null },
+ { chain: "base", token: TOKEN, trades: 0, creatorPosts: 2, holders: 0 },
+ { chain: "base", token: OTHER, trades: null, creatorPosts: null, holders: null },
+ ]);
+ assert.deepEqual(calls.launch, [[items, 3_000]]);
+ assert.equal(calls.sql.length, 1);
+ assert.deepEqual(calls.sql[0].values, [[
+ { chain_id: 4663, token: TOKEN, since: new Date(NOW - 500).toISOString() },
+ { chain_id: 8453, token: TOKEN, since: new Date(NOW - 1_000).toISOString() },
+ ], NOW / 1_000, NOW / 1_000]);
+ assert.match(calls.sql[0].sql, /s\.chain_id = w\.chain_id AND s\.token = w\.token/);
+ assert.match(calls.sql[0].sql, /s\.block_time > w\.since AND s\.block_time <= to_timestamp\(\?\)/);
+ assert.match(calls.sql[0].sql, /p\.chain_id = w\.chain_id AND p\.token = w\.token AND p\.wallet = l\.launcher/);
+ assert.match(calls.sql[0].sql, /NOT p\.hidden AND p\.parent_id IS NULL/);
+ assert.match(calls.sql[0].sql, /p\.created_at > w\.since AND p\.created_at <= to_timestamp\(\?\)/);
+ assert.doesNotMatch(calls.sql[0].sql, /\b(?:INSERT|UPDATE|DELETE)\b/);
+});
+
+test("old catchup windows are explicitly clamped to 30 days and first visits skip activity scans", async () => {
+ const { getWatchlistData, calls, WATCHLIST_WINDOW_MS } = isolatedData();
+ const old = await getWatchlistData([{ chain: "base", token: TOKEN, since: 0 }], NOW, null);
+ assert.equal(old.items[0].windowClamped, true);
+ assert.equal(old.items[0].since, NOW - WATCHLIST_WINDOW_MS);
+ const fresh = await getWatchlistData([{ chain: "base", token: TOKEN, since: null }], NOW, null);
+ assert.equal(fresh.items[0].launch?.token, TOKEN);
+ assert.equal(fresh.items[0].holders, 0);
+ assert.equal(fresh.items[0].trades, null);
+ assert.equal(fresh.items[0].creatorPosts, null);
+ assert.equal(calls.sql.length, 1);
+ assert.deepEqual(await getWatchlistData([], NOW, null), { at: NOW, indexed: true, items: [] });
+ assert.equal(calls.launch.length, 2);
+});
+
+function isolatedRoute(opts: { configured?: boolean; limited?: boolean; fail?: boolean } = {}) {
+ const data = isolatedData();
+ let reads = 0;
+ class SnapshotDate extends Date { static now() { return NOW; } }
+ const exported = compile(route, {
+ "next/server": { NextResponse: { json: (body: unknown, init: ResponseInit) => Response.json(body, init) } },
+ "@/lib/db": { dbConfigured: () => opts.configured !== false },
+ "@/lib/launchpad/editServer": { rateLimited: () => Boolean(opts.limited) },
+ "@/lib/launchpad/ethPrice": { ethUsd: async () => { reads++; return null; } },
+ "@/lib/launchpad/watchlistData": {
+ ...data,
+ getWatchlistData: async (...args: Parameters) => {
+ if (opts.fail) throw new Error("private backend diagnostic");
+ return data.getWatchlistData(...args);
+ },
+ },
+ }, SnapshotDate) as unknown as { POST: (req: Request) => Promise };
+ const request = (items: unknown) => new Request("https://example.test/api/launch/watchlist", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ items }) });
+ return { post: exported.POST, request, reads: () => reads };
+}
+
+test("watchlist route is uncached, uses server milliseconds, and avoids reads for bad input", async () => {
+ const { post, request, reads } = isolatedRoute();
+ const response = await post(request([{ chain: "base", token: TOKEN, since: NOW - 100 }]));
+ assert.equal(response.status, 200);
+ assert.equal(response.headers.get("cache-control"), "no-store");
+ assert.equal((await response.json()).at, NOW);
+ const invalid = await post(request([{ chain: "base", token: TOKEN, since: NOW + 300_001 }]));
+ assert.equal(invalid.status, 400);
+ assert.equal(reads(), 1);
+});
+
+test("watchlist route has explicit retryable outages and rate limits without leaking errors", async () => {
+ for (const opts of [{ configured: false }, { fail: true }]) {
+ const { post, request } = isolatedRoute(opts);
+ const response = await post(request([{ chain: "base", token: TOKEN, since: null }]));
+ assert.equal(response.status, 503);
+ const body = await response.json();
+ assert.equal(body.indexed, false);
+ assert.doesNotMatch(body.error, /private backend diagnostic/);
+ assert.equal(response.headers.get("cache-control"), "no-store");
+ }
+ const { post, request, reads } = isolatedRoute({ limited: true });
+ const response = await post(request([]));
+ assert.equal(response.status, 429);
+ assert.equal(response.headers.get("retry-after"), "60");
+ assert.equal(reads(), 0);
+});
+
+test("launch lookup reuses shaping in one bounded query and gates holder counts on complete backfill", async () => {
+ const helper = queries.slice(queries.indexOf("export async function getLaunchesByRefs("), queries.indexOf("/** Find which chain"));
+ const calls: { parts: string; values: unknown[] }[] = [];
+ let warmed = 0;
+ const raw = [
+ { chain_id: 8453, token: TOKEN, block_number: 10n, holders_synced_block: null, holders: 6 },
+ { chain_id: 4663, token: TOKEN, block_number: 10n, holders_synced_block: 1_000_010n, holders: 5 },
+ { chain_id: 8453, token: OTHER, block_number: 10n, holders_synced_block: 2_000_011n, holders: 8 },
+ { chain_id: 8453, token: OTHER, block_number: 10n, holders_synced_block: 9223372036854775807n, holders: 0 },
+ ];
+ const db = Object.assign((parts: TemplateStringsArray, ...values: unknown[]) => {
+ calls.push({ parts: parts.join("?"), values });
+ return parts.join("").includes("WHERE") ? Promise.resolve(raw) : { parts, values };
+ }, { unsafe: (value: string) => value });
+ const output = ts.transpileModule(helper, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText;
+ const exported = {} as { getLaunchesByRefs: (refs: { chain: string; token: string }[], usd?: number | null) => Promise<{ holders: number | null; launch: unknown }[]> };
+ new Function("exports", "maybeDb", "withStocks", "chainIdOf", "shape", "SELECT", "SYNCED_FOREVER", output)(exported, () => db, async () => { warmed++; }, (chain: string) => chain === "base" ? 8453 : 4663, (row: unknown) => row, "SELECT l.* FROM bb_launches l", 9223372036854775807n);
+ const ref = { chain: "base", token: TOKEN.toUpperCase() };
+ assert.deepEqual(await exported.getLaunchesByRefs([]), []);
+ await assert.rejects(exported.getLaunchesByRefs(Array(51).fill(ref)));
+ assert.equal(warmed, 0);
+ const result = await exported.getLaunchesByRefs([ref, { chain: "robinhood", token: TOKEN }]);
+ assert.equal(warmed, 1);
+ assert.deepEqual(result.map((item) => item.holders), [null, null, null, 0]);
+ assert.equal(calls.filter((call) => call.parts.includes("WHERE")).length, 1);
+ assert.deepEqual(calls[0].values, [8453, TOKEN]);
+ assert.deepEqual(calls[1].values, [4663, TOKEN]);
+});
diff --git a/app/src/lib/launchpad/watchlistData.ts b/app/src/lib/launchpad/watchlistData.ts
new file mode 100644
index 00000000..e07ac7ce
--- /dev/null
+++ b/app/src/lib/launchpad/watchlistData.ts
@@ -0,0 +1,141 @@
+import "server-only";
+import { maybeDb } from "@/lib/db";
+import { chainIdOf, isChainKey, type ChainKey } from "@/lib/chainPublic";
+import { getLaunchesByRefs, type LaunchRow } from "./queries";
+
+export const WATCHLIST_MAX_ITEMS = 50;
+export const WATCHLIST_WINDOW_MS = 30 * 24 * 60 * 60 * 1_000;
+export const WATCHLIST_BODY_MAX_BYTES = 16_384;
+const WATCHLIST_CLOCK_SKEW_MS = 5 * 60 * 1_000;
+
+/** All timestamps crossing this API boundary are integer Unix milliseconds. */
+export type WatchlistRequestItem = { chain: ChainKey; token: string; since: number | null };
+export type WatchlistDataItem = WatchlistRequestItem & {
+ launch: LaunchRow | null;
+ /** Null until transfer backfill is complete, or when this launch is unavailable. */
+ holders: number | null;
+ /** Indexed swaps and visible top-level creator posts in (since, at]. Null on a first visit. */
+ trades: number | null;
+ creatorPosts: number | null;
+ /** The returned `since` is the effective 30-day cutoff when this is true. */
+ windowClamped: boolean;
+};
+export type WatchlistData = { at: number; indexed: boolean; items: WatchlistDataItem[] };
+
+export class WatchlistInputError extends Error {
+ constructor(message: string, readonly status = 400) { super(message); }
+}
+
+/** Reject malformed lists before reads. Clamp small device-clock skew; never query a future baseline. */
+export function parseWatchlistRequest(value: unknown, at: number): WatchlistRequestItem[] {
+ if (!value || typeof value !== "object" || !Array.isArray((value as { items?: unknown }).items)) {
+ throw new WatchlistInputError("expected a watchlist items array");
+ }
+ const items = (value as { items: unknown[] }).items;
+ if (items.length > WATCHLIST_MAX_ITEMS) throw new WatchlistInputError(`watch up to ${WATCHLIST_MAX_ITEMS} tokens`);
+ const seen = new Set();
+ return items.map((value): WatchlistRequestItem => {
+ if (!value || typeof value !== "object") throw new WatchlistInputError("invalid watchlist item");
+ const { chain, token, since } = value as Record;
+ if (!isChainKey(chain) || typeof token !== "string" || !/^0x[0-9a-f]{40}$/i.test(token)) {
+ throw new WatchlistInputError("invalid token reference");
+ }
+ if (since !== null && since !== undefined && (typeof since !== "number" || !Number.isSafeInteger(since) || since < 0 || since > at + WATCHLIST_CLOCK_SKEW_MS)) {
+ throw new WatchlistInputError("since must be a timestamp in milliseconds within the allowed clock skew");
+ }
+ return { chain, token: token.toLowerCase(), since: since == null ? null : Math.min(since as number, at) };
+ }).filter((item) => {
+ const key = `${item.chain}:${item.token}`;
+ if (seen.has(key)) return false;
+ seen.add(key);
+ return true;
+ });
+}
+
+/** Enforce the byte limit even on chunked bodies or an inaccurate Content-Length. */
+export async function readWatchlistBody(req: Request): Promise {
+ if (req.headers.get("content-type")?.split(";", 1)[0].trim().toLowerCase() !== "application/json") {
+ throw new WatchlistInputError("send application/json", 415);
+ }
+ if (Number(req.headers.get("content-length")) > WATCHLIST_BODY_MAX_BYTES) {
+ throw new WatchlistInputError("watchlist request is too large", 413);
+ }
+ const reader = req.body?.getReader();
+ if (!reader) throw new WatchlistInputError("empty request");
+ const decoder = new TextDecoder("utf-8", { fatal: true });
+ let bytes = 0;
+ let text = "";
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ bytes += value.byteLength;
+ if (bytes > WATCHLIST_BODY_MAX_BYTES) {
+ await reader.cancel();
+ throw new WatchlistInputError("watchlist request is too large", 413);
+ }
+ text += decoder.decode(value, { stream: true });
+ }
+ text += decoder.decode();
+ return JSON.parse(text);
+ } catch (error) {
+ if (error instanceof WatchlistInputError) throw error;
+ throw new WatchlistInputError("invalid JSON body");
+ } finally {
+ reader.releaseLock();
+ }
+}
+
+/** Two bounded batch reads, no wallet identity or off-chain write. Counts reflect indexed history only. */
+export async function getWatchlistData(items: readonly WatchlistRequestItem[], at: number, ethUsd: number | null): Promise {
+ // Keep this server helper safe if a future caller bypasses the route parser.
+ const refs = parseWatchlistRequest({ items }, at);
+ const bounded = refs.map((item) => ({
+ ...item,
+ since: item.since === null ? null : Math.max(item.since, at - WATCHLIST_WINDOW_MS),
+ windowClamped: item.since !== null && item.since < at - WATCHLIST_WINDOW_MS,
+ }));
+ const blank = (): WatchlistDataItem[] => bounded.map((item) => ({ ...item, launch: null, holders: null, trades: null, creatorPosts: null }));
+ const db = maybeDb();
+ if (!db) return { at, indexed: false, items: blank() };
+ if (!refs.length) return { at, indexed: true, items: [] };
+ const windows = bounded.filter((item) => item.since !== null).map((item) => ({ chain_id: chainIdOf(item.chain), token: item.token, since: new Date(item.since!).toISOString() }));
+ type Counts = { chain_id: number; token: string; trades: string; creator_posts: string };
+ const [launches, counts] = await Promise.all([
+ getLaunchesByRefs(refs, ethUsd),
+ windows.length ? db`
+ WITH watched AS (
+ SELECT * FROM jsonb_to_recordset(${db.json(windows)}) AS r(chain_id integer, token text, since timestamptz)
+ )
+ SELECT w.chain_id, w.token, s.trades, p.creator_posts
+ FROM watched w JOIN bb_launches l ON l.chain_id = w.chain_id AND l.token = w.token
+ LEFT JOIN LATERAL (
+ SELECT count(*)::text AS trades FROM bb_launch_swaps s
+ WHERE s.chain_id = w.chain_id AND s.token = w.token
+ AND s.block_time > w.since AND s.block_time <= to_timestamp(${at / 1_000})
+ ) s ON true
+ LEFT JOIN LATERAL (
+ SELECT count(*)::text AS creator_posts FROM bb_posts p
+ WHERE p.chain_id = w.chain_id AND p.token = w.token AND p.wallet = l.launcher
+ AND NOT p.hidden AND p.parent_id IS NULL
+ AND p.created_at > w.since AND p.created_at <= to_timestamp(${at / 1_000})
+ ) p ON true` : Promise.resolve([] as Counts[]),
+ ]);
+ const launchByKey = new Map(launches.map((item) => [`${item.launch.chain}:${item.launch.token}`, item]));
+ const countByKey = new Map(counts.map((item) => [`${item.chain_id}:${item.token}`, item]));
+ return {
+ at,
+ indexed: true,
+ items: bounded.map((item) => {
+ const found = launchByKey.get(`${item.chain}:${item.token}`);
+ const activity = countByKey.get(`${chainIdOf(item.chain)}:${item.token}`);
+ return {
+ ...item,
+ launch: found?.launch ?? null,
+ holders: found?.holders ?? null,
+ trades: found && item.since !== null && activity ? Number(activity.trades) : null,
+ creatorPosts: found && item.since !== null && activity ? Number(activity.creator_posts) : null,
+ };
+ }),
+ };
+}