diff --git a/app/src/components/launchpad/LaunchForm.tsx b/app/src/components/launchpad/LaunchForm.tsx
index 74f9002e..20784faf 100644
--- a/app/src/components/launchpad/LaunchForm.tsx
+++ b/app/src/components/launchpad/LaunchForm.tsx
@@ -873,7 +873,7 @@ export default function LaunchForm({ ethUsd, gitlawbUsd = null, initialChain = D
const active = initialBuy.trim() === v;
return (
);
diff --git a/app/src/components/launchpad/LaunchList.tsx b/app/src/components/launchpad/LaunchList.tsx
index d8282f8d..286f4f9b 100644
--- a/app/src/components/launchpad/LaunchList.tsx
+++ b/app/src/components/launchpad/LaunchList.tsx
@@ -84,6 +84,7 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor
const interaction = useRef({ pointer: false, focus: false, at: 0 });
const listRef = useRef(null);
const section = usePauseOffscreen(); // a beacon pulses on every live row: not while the list is far off screen
+ const failedLoad = useRef<"view" | "more">("view");
useEffect(() => {
// the watchlist reads its own endpoint; the shared poll only carries the market list while it is on screen
@@ -138,7 +139,11 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor
}
}), [subscribe]);
- /** The URL carries the view as well as the selection, so going back to the page reopens the same tab. */
+ /**
+ * The URL carries the view as well as the selection, so going back to the page reopens the same tab. On the home page
+ * the list already holds the data, so only the address changes (history.replaceState syncs with the Next router):
+ * router.replace would re-render the whole page on the server, and a failing render there unmounts the page.
+ */
function syncUrl(next: Selection, v: ListView): URLSearchParams {
const p = new URLSearchParams();
if (next.sort !== "live") p.set("sort", next.sort);
@@ -146,7 +151,9 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor
if (next.chain) p.set("chain", next.chain);
if (next.filter) p.set("filter", next.filter);
if (v === "watchlist") p.set("view", "watchlist");
- router.replace(p.size ? `/?${p}` : "/", { scroll: false });
+ const href = p.size ? `/?${p}` : "/";
+ if (window.location.pathname === "/") window.history.replaceState(null, "", href);
+ else router.replace(href, { scroll: false }); // a chain page hands over to the home page
return p;
}
@@ -158,22 +165,27 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor
}
setView(v);
selectionRef.current = next;
- const version = ++generation.current;
- selectionRequest.current?.abort();
- const controller = new AbortController();
- selectionRequest.current = controller;
pendingOrder.current = null;
setHolding(false);
setSelection(next);
limitRef.current = PAGE_SIZE;
setLimit(PAGE_SIZE);
setLoadingMore(false);
+ syncUrl(next, v);
+ loadSelection(next);
+ }
+
+ /** Fetches the first page of a selection. A failure leaves the rows on screen and offers a retry; it never throws into render. */
+ function loadSelection(next: Selection) {
+ const version = ++generation.current;
+ selectionRequest.current?.abort();
+ const controller = new AbortController();
+ selectionRequest.current = controller;
setUpdating(true);
setLoadError(null);
- const request = syncUrl(next, v);
- request.delete("view");
- request.set("sort", s); // the URL omits the default sort, but the API defaults to "new": the request must always carry it
- request.set("limit", String(PAGE_SIZE));
+ const request = new URLSearchParams({ sort: next.sort, window: next.window, limit: String(PAGE_SIZE) }); // the API defaults the sort to "new": the request must always carry it
+ if (next.chain) request.set("chain", next.chain);
+ if (next.filter) request.set("filter", next.filter);
void fetch(`/api/launch/list?${request}`, { cache: "no-store", signal: controller.signal })
.then(async (res) => {
if (!res.ok) throw new Error("List unavailable");
@@ -185,7 +197,7 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor
setHasMore(data.has_more);
setHl(new Map());
})
- .catch(() => { if (!controller.signal.aborted && generation.current === version) setLoadError("Could not refresh this view. Live updates will retry."); })
+ .catch(() => { if (!controller.signal.aborted && generation.current === version) { failedLoad.current = "view"; setLoadError("Could not refresh this view."); } })
.finally(() => { if (generation.current === version) setUpdating(false); });
}
@@ -233,7 +245,7 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor
limitRef.current = Math.min(200, merged.length);
setLimit(limitRef.current);
} catch {
- if (generation.current === version) setLoadError("Could not load more launches. Please try again.");
+ if (generation.current === version) { failedLoad.current = "more"; setLoadError("Could not load more launches."); }
} finally {
if (generation.current === version) setLoadingMore(false);
}
@@ -342,7 +354,10 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor
{nq || filter || chain ? : Launch the first token }
: null}
- {loadError || searchResult?.error ?
{loadError || "Search is unavailable. Showing matches from loaded launches."}
: null}
+ {loadError || searchResult?.error ?
+ {loadError || "Search is unavailable. Showing matches from loaded launches."}
+ {loadError ? : null}
+
: null}
{!nq && hasMore && rows.length < 200 ? :
{nq ? "Search includes older launches." : rows.length >= 200 && hasMore ? "Showing the first 200. Search or filter to narrow the list." : shown.length ? "You're all caught up." : "One transaction. Zero platform fee."}
}
diff --git a/app/src/components/launchpad/TokenTrades.tsx b/app/src/components/launchpad/TokenTrades.tsx
index 800a352c..1afc7e57 100644
--- a/app/src/components/launchpad/TokenTrades.tsx
+++ b/app/src/components/launchpad/TokenTrades.tsx
@@ -10,7 +10,7 @@ export default function TokenTrades({ chain, symbol, quote, swaps, now }: { chai
return
Direct from the pool{swaps.length} recent swaps
{swaps.length === 0 ?
The tape is quiet.
No swaps have been indexed for this token yet. Confirmed trades appear here with an explorer link.
:
-
+
diff --git a/app/src/components/launchpad/home-floor.test.ts b/app/src/components/launchpad/home-floor.test.ts
index 09a5f6be..bc386129 100644
--- a/app/src/components/launchpad/home-floor.test.ts
+++ b/app/src/components/launchpad/home-floor.test.ts
@@ -97,7 +97,8 @@ test("the watchlist star sits outside the row link, and the tab survives a back
assert.match(list, /if \(v === "watchlist"\) p\.set\("view", "watchlist"\);/);
assert.match(home, /initialView=\{sp\.view === "watchlist" \? "watchlist" : "market"\}/);
assert.match(list, /setListParams\(view === "market" \? \{ \.\.\.selection, limit \} : null\)/, "the shared poll skips the market list while the watchlist is open");
- assert.match(list, /request\.delete\("view"\);/, "the list API never receives the view");
+ assert.match(list, /const request = new URLSearchParams\(\{ sort: next\.sort, window: next\.window, limit: String\(PAGE_SIZE\) \}\);/, "the list API request is built from the selection, so it never receives the view");
+ assert.doesNotMatch(list.slice(list.indexOf("function loadSelection"), list.indexOf("const nq = normalizeQuery(q);")), /request\.set\("view"/);
});
test("the bubble map lays out once per update with no animation timer, and stays decorative like the river", () => {
diff --git a/app/src/components/launchpad/launch-form.test.ts b/app/src/components/launchpad/launch-form.test.ts
index edb8907c..1c39d167 100644
--- a/app/src/components/launchpad/launch-form.test.ts
+++ b/app/src/components/launchpad/launch-form.test.ts
@@ -178,3 +178,8 @@ test("the preview draws the real board card from the form, with no link and noth
assert.match(preview, /col-start-1 row-start-1/);
assert.doesNotMatch(preview, /\bfetch\s*\(/);
});
+
+test("a first-buy chip is labelled by the one formatter, so its label never rounds away from its value", () => {
+ assert.match(source, /\{fmtQuoteUnits\(Number\(v\), quote\.decimals\)\} \{quote\.symbol\}<\/span>/);
+ assert.doesNotMatch(source, /fmtCompact\(Number\(v\), 0\)/);
+});
diff --git a/app/src/components/launchpad/list-load-errors.test.ts b/app/src/components/launchpad/list-load-errors.test.ts
new file mode 100644
index 00000000..176b427f
--- /dev/null
+++ b/app/src/components/launchpad/list-load-errors.test.ts
@@ -0,0 +1,33 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import test from "node:test";
+
+/**
+ * Source contracts for how the home board survives a failing list: Volume with a window once answered 502, and the
+ * whole page unmounted. A failed read now stays inline, offers a retry, and picking a view never re-renders the page on the server.
+ */
+const read = (file: string) => readFileSync(new URL(file, import.meta.url), "utf8").replaceAll("\r\n", "\n");
+const list = read("./LaunchList.tsx");
+const live = read("./LiveProvider.tsx");
+
+test("a non-OK list or live response is caught, never thrown into render", () => {
+ const selection = list.slice(list.indexOf("function loadSelection"), list.indexOf("const nq = normalizeQuery(q);"));
+ assert.match(selection, /if \(!res\.ok\) throw new Error\("List unavailable"\);/);
+ assert.match(selection, /\.catch\(\(\) => \{[^\n]*setLoadError\(/, "the rejection lands in the inline error");
+ assert.match(live, /if \(!res\.ok\) return;/, "the shared poll keeps the last snapshot");
+ assert.match(live, /catch \{\s*\/\* offline; keep last \*\//);
+});
+
+test("the inline error is an alert with a retry for both a failed view and a failed page", () => {
+ assert.match(list, /Try again<\/button>/);
+ assert.match(list, /failedLoad\.current = "view"/);
+ assert.match(list, /failedLoad\.current = "more"/);
+});
+
+test("on the home page a selection changes the address only; it does not navigate through the server", () => {
+ const sync = list.slice(list.indexOf("function syncUrl"), list.indexOf("function pick("));
+ assert.match(sync, /window\.location\.pathname === "\/"\) window\.history\.replaceState\(null, "", href\)/);
+ assert.match(sync, /else router\.replace\(href/, "a chain page still hands over to the home page");
+});
diff --git a/app/src/components/launchpad/token-page.test.ts b/app/src/components/launchpad/token-page.test.ts
index 488321fa..e242df92 100644
--- a/app/src/components/launchpad/token-page.test.ts
+++ b/app/src/components/launchpad/token-page.test.ts
@@ -53,3 +53,10 @@ test("the About card sits under the trade box and links only checked socials", (
assert.match(about, /const \{ x, site, host \} = safeSocials\(\{ website, x_handle \}\);/);
assert.match(about, /rel="noopener noreferrer nofollow"/);
});
+
+test("the trades scroller is the containing block for its sr-only labels, so a long tape adds no blank page height", () => {
+ const trades = read("./TokenTrades.tsx");
+ // every row carries an absolutely positioned sr-only label; without a positioned scroller they escape its clipping and stretch the page
+ assert.match(trades, /View transaction, <\/span>/);
+ assert.match(trades, /
db/);
// a failed ownership read must not take the feed down, and must not let a picture through unchecked: every stored picture counts as a copy
assert.match(queries.slice(queries.indexOf("async function pictureCopies")), /catch \(error\) \{\n\s*console\.warn\("\[feed\] picture ownership read failed:"[^\n]*\n\s*return new Set\(launches\.filter\(\(r\) => pictureKey\(r\.image_url\) !== null\)/);
});
diff --git a/app/src/lib/launchpad/image-store.test.ts b/app/src/lib/launchpad/image-store.test.ts
new file mode 100644
index 00000000..7f05b1a9
--- /dev/null
+++ b/app/src/lib/launchpad/image-store.test.ts
@@ -0,0 +1,30 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readObject } from "./imageStore.ts";
+
+const KEY = `t/${"a".repeat(48)}.webp`;
+const body = (bytes: number[]) => ({ transformToByteArray: async () => Uint8Array.from(bytes) });
+const client = (send: (command: unknown, options?: { abortSignal?: AbortSignal }) => Promise) => ({ send }) as unknown as Parameters[0];
+
+test("a read that never answers is a miss within the timeout, and the request is aborted", async () => {
+ let signal: AbortSignal | undefined;
+ const hang = client((_command, options) => { signal = options?.abortSignal; return new Promise(() => {}); });
+ const started = Date.now();
+ assert.equal(await readObject(hang, "bucket", KEY, 40), null);
+ assert.ok(Date.now() - started < 1_000, "returned about when the timeout fired");
+ assert.equal(signal?.aborted, true, "the SDK call was handed a signal and it fired");
+});
+
+test("a body that stalls after the headers is also a miss", async () => {
+ const stalled = client(async () => ({ Body: { transformToByteArray: () => new Promise(() => {}) } }));
+ assert.equal(await readObject(stalled, "bucket", KEY, 40), null);
+});
+
+test("a rejected read is a miss, never a throw", async () => {
+ assert.equal(await readObject(client(async () => { throw new Error("NoSuchKey"); }), "bucket", KEY, 40), null);
+});
+
+test("a fast read returns its bytes", async () => {
+ const got = await readObject(client(async () => ({ Body: body([1, 2, 3]) })), "bucket", KEY, 1_000);
+ assert.deepEqual([...got!], [1, 2, 3]);
+});
diff --git a/app/src/lib/launchpad/imageStore.ts b/app/src/lib/launchpad/imageStore.ts
index a1ee48c5..7cb7944d 100644
--- a/app/src/lib/launchpad/imageStore.ts
+++ b/app/src/lib/launchpad/imageStore.ts
@@ -67,6 +67,29 @@ export async function putImage(key: string, body: Buffer): Promise {
return imageUrlFor(c.publicBase, key);
}
+const READ_TIMEOUT_MS = 3_000;
+type ObjectClient = Pick;
+
+/**
+ * One bucket read that gives up after `timeoutMs`: the abort signal cancels the request, and the race also covers a body
+ * that stalls after the headers arrive. A timeout is a miss (null), like any other failed read, and never throws.
+ */
+export async function readObject(client: ObjectClient, bucket: string, key: string, timeoutMs = READ_TIMEOUT_MS): Promise {
+ const abort = new AbortController();
+ const timer = setTimeout(() => abort.abort(), timeoutMs);
+ try {
+ const read = (async () => {
+ const r = await client.send(new GetObjectCommand({ Bucket: bucket, Key: key }), { abortSignal: abort.signal });
+ const bytes = await r.Body?.transformToByteArray();
+ return bytes ? Buffer.from(bytes) : null;
+ })().catch(() => null);
+ const timedOut = new Promise((resolve) => abort.signal.addEventListener("abort", () => resolve(null), { once: true }));
+ return await Promise.race([read, timedOut]);
+ } finally {
+ clearTimeout(timer);
+ }
+}
+
const KEY_RE = /^t\/[0-9a-f]{48}\.webp$/;
/**
@@ -79,9 +102,7 @@ export async function readImage(key: string): Promise {
if (c.kind === "off" || !KEY_RE.test(key)) return null;
try {
if (c.kind === "local") return await readFile(path.join(c.dir, key));
- const r = await c.client.send(new GetObjectCommand({ Bucket: c.bucket, Key: key }));
- const bytes = await r.Body?.transformToByteArray();
- return bytes ? Buffer.from(bytes) : null;
+ return await readObject(c.client, c.bucket, key);
} catch {
return null;
}
diff --git a/app/src/lib/launchpad/math.test.ts b/app/src/lib/launchpad/math.test.ts
index 54261dce..5a2a344f 100644
--- a/app/src/lib/launchpad/math.test.ts
+++ b/app/src/lib/launchpad/math.test.ts
@@ -157,6 +157,15 @@ test("fmtQuoteUnits: stables 2dp, 18-dec ETH-style below 100K, compact above wit
assert.equal(fmtQuoteUnits(594_540_000, 18), "594.54M");
});
+test("fmtQuoteUnits labels a first-buy chip with the amount it sets", () => {
+ assert.equal(fmtQuoteUnits(1_000_000, 18), "1M");
+ assert.equal(fmtQuoteUnits(2_500_000, 18), "2.5M", "the middle TWIG chip once read 3M");
+ assert.equal(fmtQuoteUnits(5_000_000, 18), "5M");
+ assert.equal(fmtQuoteUnits(750_000, 18), "750K");
+ assert.equal(fmtQuoteUnits(100_000, 18), "100K");
+ assert.equal(fmtQuoteUnits(12_345_678, 18), "12.35M");
+});
+
test("fmtUnitsExact keeps every digit of a raw amount: no float, no rounding, trailing zeros trimmed", () => {
assert.equal(fmtUnitsExact("0", 18), "0");
assert.equal(fmtUnitsExact("1", 18), "0.000000000000000001");
diff --git a/app/src/lib/launchpad/queries.ts b/app/src/lib/launchpad/queries.ts
index 3ebf55d2..0a5bfcde 100644
--- a/app/src/lib/launchpad/queries.ts
+++ b/app/src/lib/launchpad/queries.ts
@@ -251,7 +251,7 @@ function shape(raw: Raw & { last_swap_block?: bigint; last_swap_log?: number; lo
// bb_launches.last_trade_at also moves on the launcher's own swaps.
const OUTSIDE = `s.trader <> l.launcher AND s.block_number > l.block_number + ${SNIPER_BLOCKS}`;
const HOUR = `s.block_time > now() - interval '1 hour'`;
-const SELECT = `SELECT l.*, m.description, m.image_url, m.banner_url, m.website, m.x_handle,
+export const SELECT = `SELECT l.*, m.description, m.image_url, m.banner_url, m.website, m.x_handle,
w.n1 AS trades_1h, w.t1 AS traders_1h, w.t1_ex AS traders_1h_ex, w.v1 AS volume_1h, w.n24 AS trades_24h, w.t24_ex AS traders_24h_ex, w.v24 AS volume_24h, w.last_outside_at AS last_outside_trade_at
FROM bb_launches l
LEFT JOIN bb_launch_meta m ON m.chain_id = l.chain_id AND m.token = l.token
@@ -273,6 +273,8 @@ export function parseSort(raw: string | null | unde
return LAUNCH_SORTS.includes(raw as LaunchSort) ? (raw as LaunchSort) : fallback;
}
export const VOLUME_WINDOWS: VolumeWindow[] = ["1h", "24h", "all"];
+/** The raw-quote volume a Volume sort ranks by, per window: the lateral `w` in SELECT carries v1 and v24; "all" is the launch's own total. */
+export const VOLUME_COLUMN_SQL: Record = { "1h": "w.v1::numeric", "24h": "w.v24::numeric", all: "l.volume_quote" };
export type ListOpts = { sort?: LaunchSort; window?: VolumeWindow; chain?: ChainKey | null; filter?: LaunchFilter | null; limit?: number; offset?: number; launcher?: string; ethUsd?: number | null };
const NATIVE_ADDR = "0x0000000000000000000000000000000000000000";
@@ -341,7 +343,7 @@ export async function listLaunchesPage(opts: ListOpts = {}): Promise {
const stableDec = stables.filter((s) => s.decimals !== 18);
const stableDecCase = stableDec.length ? stableDec.map((s) => db`WHEN (l.chain_id = ${chainIdOf(s.chain)} AND l.quote = ${s.address}) THEN ${s.decimals}`).reduce((acc, c) => db`${acc} ${c}`) : db``;
const qd = db`(CASE ${stableDecCase} ${decCase} ELSE 18 END)`;
- const volCol = win === "1h" ? db`w1.v::numeric` : win === "24h" ? db`w24.v::numeric` : db`l.volume_quote`;
+ const volCol = db.unsafe(VOLUME_COLUMN_SQL[win]);
const volUsd = db`(${volCol} / power(10, ${qd}) * ${usdPerUnit})`;
// quote per token = 1 / (1.0001^tick · 10^(qd-18)); mcap = that · supply/1e18 · usd
const mcapUsd = db`((1.0 / (power(1.0001, COALESCE(l.tick, l.start_tick)::double precision) * power(10, ${qd} - 18))) * (l.supply / 1e18) * ${usdPerUnit})`;
@@ -443,7 +445,11 @@ async function pictureCopies(db: NonNullable>, launch
const keys = [...new Set(launches.map((r) => pictureKey(r.image_url)).filter((k): k is string => k !== null))];
if (keys.length === 0) return new Set();
try {
- const all = await db`SELECT chain_id, token, created_at, image_url FROM bb_launch_meta WHERE image_url ILIKE ANY(${keys.map((k) => `%/${k}%`)}::text[])`;
+ // a scan of bb_launch_meta (about 30 ms at 5,000 rows), and the home page asks on every render: read it once per half minute for a
+ // given set of pictures. Stale is safe: a copy is judged against the earliest registration, which is older than any copy and
+ // already in the answer, and a launch with a new picture changes the set, so it is read fresh.
+ const sorted = [...keys].sort();
+ const all = await memo(`picture-owners:${sorted.join(",")}`, 30_000, () => db`SELECT chain_id, token, created_at, image_url FROM bb_launch_meta WHERE image_url ILIKE ANY(${sorted.map((k) => `%/${k}%`)}::text[])`);
return reusedPictures(launches, all);
} catch (error) {
console.warn("[feed] picture ownership read failed:", error instanceof Error ? error.message : error);
diff --git a/app/src/lib/launchpad/volume-window.test.ts b/app/src/lib/launchpad/volume-window.test.ts
new file mode 100644
index 00000000..5376aa2a
--- /dev/null
+++ b/app/src/lib/launchpad/volume-window.test.ts
@@ -0,0 +1,39 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { SELECT, VOLUME_COLUMN_SQL, VOLUME_WINDOWS } from "./queries.ts";
+
+/**
+ * The Volume sort orders by a column per window. The window volumes live on the lateral subquery aliased `w`
+ * (v1, v24); an ORDER BY naming an alias the query does not define is a 502 on the home board.
+ */
+const lateral = SELECT.slice(SELECT.indexOf("LEFT JOIN LATERAL ("), SELECT.lastIndexOf(") w ON"));
+const wColumns = new Set([...lateral.matchAll(/\bAS (\w+)/g)].map((m) => m[1]));
+// aliases visible to the ORDER BY (the lateral's own inner alias is not): "FROM bb_launches l", "JOIN bb_launch_meta m", "LATERAL (...) w"
+const definedAliases = new Set([...SELECT.replace(lateral, "LATERAL (...").matchAll(/\b(?:FROM|JOIN) bb_\w+ (\w+)|\) (\w+) ON/g)].map((m) => m[1] ?? m[2]));
+
+test("the lateral subquery is still aliased w and carries the window volumes", () => {
+ assert.ok(SELECT.includes(") w ON true"));
+ assert.ok(wColumns.has("v1") && wColumns.has("v24"));
+ assert.deepEqual([...definedAliases].sort(), ["l", "m", "w"]);
+});
+
+test("every volume window orders by columns the query defines", () => {
+ assert.deepEqual(Object.keys(VOLUME_COLUMN_SQL).sort(), [...VOLUME_WINDOWS].sort(), "one column per window");
+ for (const win of VOLUME_WINDOWS) {
+ const refs = [...VOLUME_COLUMN_SQL[win].matchAll(/\b(\w+)\.(\w+)\b/g)];
+ assert.ok(refs.length > 0, win);
+ for (const [, alias, column] of refs) {
+ assert.ok(definedAliases.has(alias), `${win}: alias ${alias} is not in the query`);
+ if (alias === "w") assert.ok(wColumns.has(column), `${win}: w.${column} is not a lateral column`);
+ }
+ }
+});
+
+test("listLaunchesPage ranks by that table, not by aliases of its own", () => {
+ const source = readFileSync(new URL("./queries.ts", import.meta.url), "utf8");
+ assert.match(source, /const volCol = db\.unsafe\(VOLUME_COLUMN_SQL\[win\]\);/);
+ // the two aliases the query never defined: the 502 came back the day someone typed them again
+ assert.doesNotMatch(source, /\bw1\.v\b|\bw24\.v\b/);
+});
+