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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 35 additions & 30 deletions app/src/app/api/launch/candles/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,34 +20,39 @@ export async function GET(req: Request) {
const token = (u.searchParams.get("token") ?? "").toLowerCase();
const interval = u.searchParams.get("interval") ?? "5m";
if (!isChainKey(chain) || !isAddress(token) || !isInterval(interval)) return NextResponse.json({ error: "bad params" }, { status: 400 });
const usd = await ethUsd();
const l = await memo(`launch:${chain}:${token}`, 3_000, () => getLaunch(chain, token, usd));
if (!l) return NextResponse.json({ error: "not found" }, { status: 404 });
// getLaunch resolves issuer-registry stock quotes too; the static quote list
// would relabel them "?" and could aggregate with the wrong decimals.
const q = { symbol: l.quote_symbol, decimals: l.quote_decimals };
const intervalS = INTERVALS[interval];
const launchT = Math.floor(new Date(l.block_time).getTime() / 1000);
const requestedAt = Math.floor(Date.now() / 1000);
const fromRaw = Number(u.searchParams.get("from"));
const from = boundedCandleFrom(fromRaw, launchT, requestedAt, intervalS);
const wallet = u.searchParams.get("wallet");
const snapshot = await memo(`candles:${chain}:${token}:${interval}:${from}`, 3_000, async () => {
const asOf = requestedAt;
const [candles, priorPrice] = await Promise.all([
getCandles(chain, token, intervalS, from, q.decimals, asOf),
getCandleBaseline(chain, token, from, q.decimals),
]);
return { candles, priorPrice, asOf };
});
// A cache hit can predate this request. Markers must share its cutoff so a
// new wallet trade cannot appear ahead of the corresponding OHLCV update.
const mine = wallet && isAddress(wallet) ? await getWalletSwaps(chain, token, wallet, snapshot.asOf) : null;
// launch price in quote per token, from the start tick
const launchPrice = 1 / (Math.pow(1.0001, l.start_tick) * Math.pow(10, q.decimals - 18));
const baseline = { price: snapshot.priorPrice ?? launchPrice, hasPriorTrades: snapshot.priorPrice !== null };
return NextResponse.json(
{ interval, from, asOf: snapshot.asOf, baseline, launch: { t: launchT, price: launchPrice }, quote: { symbol: q.symbol, decimals: q.decimals, usd: l.quote_usd }, supply: Number(BigInt(l.supply)) / 1e18, candles: snapshot.candles, mine },
{ headers: { "cache-control": "no-store" } },
);
try {
const usd = await ethUsd();
const l = await memo(`launch:${chain}:${token}`, 3_000, () => getLaunch(chain, token, usd));
if (!l) return NextResponse.json({ error: "not found" }, { status: 404 });
// getLaunch resolves issuer-registry stock quotes too; the static quote list
// would relabel them "?" and could aggregate with the wrong decimals.
const q = { symbol: l.quote_symbol, decimals: l.quote_decimals };
const intervalS = INTERVALS[interval];
const launchT = Math.floor(new Date(l.block_time).getTime() / 1000);
const requestedAt = Math.floor(Date.now() / 1000);
const fromRaw = Number(u.searchParams.get("from"));
const from = boundedCandleFrom(fromRaw, launchT, requestedAt, intervalS);
const wallet = u.searchParams.get("wallet");
const snapshot = await memo(`candles:${chain}:${token}:${interval}:${from}`, 3_000, async () => {
const asOf = requestedAt;
const [candles, priorPrice] = await Promise.all([
getCandles(chain, token, intervalS, from, q.decimals, asOf),
getCandleBaseline(chain, token, from, q.decimals),
]);
return { candles, priorPrice, asOf };
});
// A cache hit can predate this request. Markers must share its cutoff so a
// new wallet trade cannot appear ahead of the corresponding OHLCV update.
const mine = wallet && isAddress(wallet) ? await getWalletSwaps(chain, token, wallet, snapshot.asOf) : null;
// launch price in quote per token, from the start tick
const launchPrice = 1 / (Math.pow(1.0001, l.start_tick) * Math.pow(10, q.decimals - 18));
const baseline = { price: snapshot.priorPrice ?? launchPrice, hasPriorTrades: snapshot.priorPrice !== null };
return NextResponse.json(
{ interval, from, asOf: snapshot.asOf, baseline, launch: { t: launchT, price: launchPrice }, quote: { symbol: q.symbol, decimals: q.decimals, usd: l.quote_usd }, supply: Number(BigInt(l.supply)) / 1e18, candles: snapshot.candles, mine },
{ headers: { "cache-control": "no-store" } },
);
} catch (err) {
console.error("[launch] candles failed:", err instanceof Error ? err.message : err);
return NextResponse.json({ error: "could not load candles" }, { status: 502 });
}
}
9 changes: 7 additions & 2 deletions app/src/app/api/launch/feed/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ import { memo } from "@/lib/launchpad/memo";
export const dynamic = "force-dynamic";

export async function GET() {
const usd = await ethUsd();
return NextResponse.json({ items: await memo("feed", 2_000, () => getLaunchFeed(24, usd)) }, { headers: { "cache-control": "no-store" } });
try {
const usd = await ethUsd();
return NextResponse.json({ items: await memo("feed", 2_000, () => getLaunchFeed(24, usd)) }, { headers: { "cache-control": "no-store" } });
} catch (err) {
console.error("[launch] feed failed:", err instanceof Error ? err.message : err);
return NextResponse.json({ error: "could not load feed" }, { status: 502 });
}
}
11 changes: 8 additions & 3 deletions app/src/app/api/launch/holders/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,12 @@ export async function GET(req: Request) {
const chain = u.searchParams.get("chain");
const token = u.searchParams.get("token");
if (!isChainKey(chain) || !token || !isAddress(token)) return NextResponse.json({ error: "bad params" }, { status: 400 });
const panel = await memo(`holders:${chain}:${token.toLowerCase()}`, 5_000, () => getHolderPanel(chain, token));
if (!panel) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json(panel, { headers: { "cache-control": "no-store" } });
try {
const panel = await memo(`holders:${chain}:${token.toLowerCase()}`, 5_000, () => getHolderPanel(chain, token));
if (!panel) return NextResponse.json({ error: "not found" }, { status: 404 });
return NextResponse.json(panel, { headers: { "cache-control": "no-store" } });
} catch (err) {
console.error("[launch] holders failed:", err instanceof Error ? err.message : err);
return NextResponse.json({ error: "could not load holders" }, { status: 502 });
}
}
11 changes: 8 additions & 3 deletions app/src/app/api/launch/list/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@ export async function GET(req: Request) {
const filter = isFilter(f) ? f : null;
const limit = clampLimit(u.searchParams.get("limit"), 50);
const offset = clampOffset(u.searchParams.get("offset"));
const usd = await ethUsd();
const page = await listLaunchesPage({ sort, window, chain, filter, limit, offset, ethUsd: usd });
return NextResponse.json({ sort, window, chain, filter, limit, offset, has_more: page.hasMore, ethUsd: usd, launches: page.items }, { headers: { "cache-control": "no-store" } });
try {
const usd = await ethUsd();
const page = await listLaunchesPage({ sort, window, chain, filter, limit, offset, ethUsd: usd });
return NextResponse.json({ sort, window, chain, filter, limit, offset, has_more: page.hasMore, ethUsd: usd, launches: page.items }, { headers: { "cache-control": "no-store" } });
} catch (err) {
console.error("[launch] list failed:", err instanceof Error ? err.message : err);
return NextResponse.json({ error: "could not load list" }, { status: 502 });
}
}
34 changes: 20 additions & 14 deletions app/src/app/api/launch/live/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { VOLUME_WINDOWS, getLaunchFeed, getLaunchTotals, getTrending, isTrending
import { ethUsd } from "@/lib/launchpad/ethPrice";
import { memo } from "@/lib/launchpad/memo";
import { listFeed } from "@/lib/launchpad/postsServer";
import { FEED_POSTS_LIMIT, feedPostsKey } from "@/lib/launchpad/posts-paging";

export const dynamic = "force-dynamic";

Expand All @@ -20,18 +21,23 @@ export async function GET(req: Request) {
const f = u.searchParams.get("filter");
const filter = isFilter(f) ? f : null;
const limit = clampLimit(u.searchParams.get("limit"), 40);
const usd = await ethUsd();
const listOpts = sort ? { sort, window, chain, filter, limit, offset: 0, ethUsd: usd } : null;
// the home list's first page is the strip's candidate set: rank it instead of running the live query a second time;
// any other view fetches the strip in the same batch as everything else
const deriveTrending = listOpts !== null && isTrendingSource(listOpts);
const [feed, totals, page, posts, fetchedTrending] = await Promise.all([
memo("feed", 2_000, () => getLaunchFeed(24, usd)),
memo("totals", 2_000, () => getLaunchTotals(usd)),
listOpts ? memo(`list:${chain ?? "all"}:${sort}:${window}:${filter ?? "-"}:${limit}`, 2_000, () => listLaunchesPage(listOpts)) : Promise.resolve(null),
memo("feed-posts:0", 2_000, () => listFeed(30, 0)),
deriveTrending ? Promise.resolve(null) : memo("trending", 2_000, () => getTrending(usd)),
]);
const trending = fetchedTrending ?? trendingFrom(page?.items ?? []);
return NextResponse.json({ at: Date.now(), feed, totals, ethUsd: usd, sort, window, chain, filter, limit, has_more: page?.hasMore ?? null, launches: page?.items ?? null, posts, trending }, { headers: { "cache-control": "no-store" } });
try {
const usd = await ethUsd();
const listOpts = sort ? { sort, window, chain, filter, limit, offset: 0, ethUsd: usd } : null;
// the home list's first page is the strip's candidate set: rank it instead of running the live query a second time;
// any other view fetches the strip in the same batch as everything else
const deriveTrending = listOpts !== null && isTrendingSource(listOpts);
const [feed, totals, page, posts, fetchedTrending] = await Promise.all([
memo("feed", 2_000, () => getLaunchFeed(24, usd)),
memo("totals", 2_000, () => getLaunchTotals(usd)),
listOpts ? memo(`list:${chain ?? "all"}:${sort}:${window}:${filter ?? "-"}:${limit}`, 2_000, () => listLaunchesPage(listOpts)) : Promise.resolve(null),
memo(feedPostsKey(0), 2_000, () => listFeed(FEED_POSTS_LIMIT, 0)), // the same entry /api/posts?feed=1 serves
deriveTrending ? Promise.resolve(null) : memo("trending", 2_000, () => getTrending(usd)),
]);
const trending = fetchedTrending ?? trendingFrom(page?.items ?? []);
return NextResponse.json({ at: Date.now(), feed, totals, ethUsd: usd, sort, window, chain, filter, limit, has_more: page?.hasMore ?? null, launches: page?.items ?? null, posts, trending }, { headers: { "cache-control": "no-store" } });
} catch (err) {
console.error("[launch] live failed:", err instanceof Error ? err.message : err);
return NextResponse.json({ error: "could not load live snapshot" }, { status: 502 });
}
}
11 changes: 8 additions & 3 deletions app/src/app/api/launch/search/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@ export async function GET(req: Request) {
const u = new URL(req.url);
const q = (u.searchParams.get("q") ?? "").slice(0, 80);
const c = u.searchParams.get("chain");
const usd = await ethUsd();
const launches = await searchLaunches(q, { chain: isChainKey(c) ? c : null, limit: 20, ethUsd: usd });
return NextResponse.json({ q, launches }, { headers: { "cache-control": "no-store" } });
try {
const usd = await ethUsd();
const launches = await searchLaunches(q, { chain: isChainKey(c) ? c : null, limit: 20, ethUsd: usd });
return NextResponse.json({ q, launches }, { headers: { "cache-control": "no-store" } });
} catch (err) {
console.error("[launch] search failed:", err instanceof Error ? err.message : err);
return NextResponse.json({ error: "could not search" }, { status: 502 });
}
}
17 changes: 11 additions & 6 deletions app/src/app/api/me/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,15 @@ export const dynamic = "force-dynamic";
export async function GET(req: Request) {
const w = (new URL(req.url).searchParams.get("wallet") ?? "").toLowerCase();
if (!isAddress(w)) return NextResponse.json({ error: "bad wallet" }, { status: 400 });
const usd = await ethUsd();
const data = await memo(`me:${w}`, 3_000, async () => {
const [launches, tokens, trades] = await Promise.all([listLaunches({ launcher: w, limit: 200, ethUsd: usd }), getWalletTokens(w, usd), getWalletTrades(w, usd, 50)]);
return { wallet: w, ethUsd: usd, launches, tokens, trades };
});
return NextResponse.json(data, { headers: { "cache-control": "no-store" } });
try {
const usd = await ethUsd();
const data = await memo(`me:${w}`, 3_000, async () => {
const [launches, tokens, trades] = await Promise.all([listLaunches({ launcher: w, limit: 200, ethUsd: usd }), getWalletTokens(w, usd), getWalletTrades(w, usd, 50)]);
return { wallet: w, ethUsd: usd, launches, tokens, trades };
});
return NextResponse.json(data, { headers: { "cache-control": "no-store" } });
} catch (err) {
console.error("[me] wallet failed:", err instanceof Error ? err.message : err);
return NextResponse.json({ error: "could not load wallet" }, { status: 502 });
}
}
18 changes: 14 additions & 4 deletions app/src/app/api/posts/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,22 +4,32 @@ import { isChainKey } from "@/lib/chainPublic";
import { rateLimited } from "@/lib/launchpad/editServer";
import { createPost, listFeed, listTokenPosts } from "@/lib/launchpad/postsServer";
import { memo } from "@/lib/launchpad/memo";
import { parseTokenPostsPaging, postsCursorKey } from "@/lib/launchpad/posts-paging";
import { FEED_POSTS_LIMIT, feedPostsKey, parseFeedPaging, parseTokenPostsPaging, postsCursorKey } from "@/lib/launchpad/posts-paging";

export const dynamic = "force-dynamic";

/** GET /api/posts?chain=&token=[&limit=&before=] → one page of posts on a token (+ muted flag, nextCursor); GET /api/posts?feed=1&offset= → global human feed. */
export async function GET(req: Request) {
const u = new URL(req.url);
if (u.searchParams.get("feed")) {
const offset = Number(u.searchParams.get("offset") ?? 0) || 0;
return NextResponse.json({ posts: await memo(`feed-posts:${offset}`, 2_000, () => listFeed(30, offset)) }, { headers: { "cache-control": "no-store" } });
const { offset } = parseFeedPaging({ offset: u.searchParams.get("offset") });
try {
return NextResponse.json({ posts: await memo(feedPostsKey(offset), 2_000, () => listFeed(FEED_POSTS_LIMIT, offset)) }, { headers: { "cache-control": "no-store" } });
} catch (err) {
console.error("[posts] feed failed:", err instanceof Error ? err.message : err);
return NextResponse.json({ error: "could not load feed" }, { status: 502 });
}
}
const chain = u.searchParams.get("chain");
const token = (u.searchParams.get("token") ?? "").toLowerCase();
if (!isChainKey(chain) || !isAddress(token)) return NextResponse.json({ error: "bad params" }, { status: 400 });
const { limit, beforeId } = parseTokenPostsPaging({ limit: u.searchParams.get("limit"), before: u.searchParams.get("before") });
return NextResponse.json(await memo(postsCursorKey(chain, token, limit, beforeId), 2_000, () => listTokenPosts(chain, token, limit, beforeId)), { headers: { "cache-control": "no-store" } });
try {
return NextResponse.json(await memo(postsCursorKey(chain, token, limit, beforeId), 2_000, () => listTokenPosts(chain, token, limit, beforeId)), { headers: { "cache-control": "no-store" } });
} catch (err) {
console.error("[posts] token posts failed:", err instanceof Error ? err.message : err);
return NextResponse.json({ error: "could not load comments" }, { status: 502 });
}
}

/** POST {chain, token, wallet, parentId?, body, nonce, ts, signature} → new post. */
Expand Down
33 changes: 32 additions & 1 deletion app/src/lib/launchpad/posts-paging.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { TOKEN_POSTS_DEFAULT_LIMIT, TOKEN_POSTS_MAX_LIMIT, nextPostsCursor, parseTokenPostsPaging, postsCursorKey } from "./posts-paging.ts";
import { readFileSync } from "node:fs";
import { FEED_POSTS_LIMIT, FEED_POSTS_MAX_OFFSET, clampFeedOffset, TOKEN_POSTS_DEFAULT_LIMIT, TOKEN_POSTS_MAX_LIMIT, feedPostsKey, nextPostsCursor, parseFeedPaging, parseTokenPostsPaging, postsCursorKey } from "./posts-paging.ts";

test("parseTokenPostsPaging defaults, clamps and reads the cursor", () => {
assert.deepEqual(parseTokenPostsPaging({}), { limit: TOKEN_POSTS_DEFAULT_LIMIT, beforeId: null });
Expand Down Expand Up @@ -37,3 +38,33 @@ test("default page preserves existing discussions until the client follows curso
assert.ok(TOKEN_POSTS_DEFAULT_LIMIT >= 51, "1 parent + 50 replies arrive on the first page");
assert.equal(nextPostsCursor(Array.from({ length: 75 }, (_, i) => 75 - i), TOKEN_POSTS_DEFAULT_LIMIT), null);
});

test("parseFeedPaging clamps the offset so bad queries cannot thrash the memo or force huge scans", () => {
assert.deepEqual(parseFeedPaging({}), { offset: 0 });
assert.deepEqual(parseFeedPaging({ offset: null }), { offset: 0 }, "absent query param uses 0");
assert.deepEqual(parseFeedPaging({ offset: "" }), { offset: 0 });
assert.deepEqual(parseFeedPaging({ offset: "abc" }), { offset: 0 });
assert.deepEqual(parseFeedPaging({ offset: -5 }), { offset: 0 }, "negative collapses to 0");
assert.deepEqual(parseFeedPaging({ offset: "40" }), { offset: 40 });
assert.deepEqual(parseFeedPaging({ offset: 1.9 }), { offset: 1 }, "truncates");
assert.deepEqual(parseFeedPaging({ offset: 9999999999 }), { offset: FEED_POSTS_MAX_OFFSET }, "caps huge offsets");
assert.equal(FEED_POSTS_LIMIT, 30, "feed page size matches the previous response");
});

test("feedPostsKey normalizes the offset into one memo key", () => {
assert.equal(feedPostsKey(0), "feed-posts:0");
assert.equal(feedPostsKey(-5), "feed-posts:0");
assert.equal(feedPostsKey(1.9), "feed-posts:1");
assert.equal(feedPostsKey(9999999999), `feed-posts:${FEED_POSTS_MAX_OFFSET}`);
assert.equal(feedPostsKey(NaN), "feed-posts:0");
});

test("the feed query clamps with the same helper as the parser and the memo key", () => {
assert.equal(clampFeedOffset(1.9), 1);
assert.equal(clampFeedOffset(Number.POSITIVE_INFINITY), 0);
assert.equal(clampFeedOffset(1e20), FEED_POSTS_MAX_OFFSET, "a huge offset never reaches Postgres as a non-bigint");
const server = readFileSync(new URL("./postsServer.ts", import.meta.url), "utf8");
assert.match(server, /OFFSET \$\{clampFeedOffset\(offset\)\}/);
const live = readFileSync(new URL("../../app/api/launch/live/route.ts", import.meta.url), "utf8");
assert.match(live, /memo\(feedPostsKey\(0\), 2_000, \(\) => listFeed\(FEED_POSTS_LIMIT, 0\)\)/, "live and /api/posts share one feed-posts entry");
});
19 changes: 19 additions & 0 deletions app/src/lib/launchpad/posts-paging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,25 @@ export function postsCursorKey(chain: string, token: string, limit: number, befo
return `posts:${chain}:${token.toLowerCase()}:${limit}:${beforeId ?? "head"}`;
}

/** Global human feed page: fixed 30 rows, offset clamped so one bad query cannot thrash the memo or force a huge OFFSET scan. */
export const FEED_POSTS_LIMIT = 30;
export const FEED_POSTS_MAX_OFFSET = 100_000;

/** A whole offset in [0, FEED_POSTS_MAX_OFFSET]; anything non-finite is 0. The one clamp the parser, memo key and query share. */
export function clampFeedOffset(n: number): number {
return Number.isFinite(n) ? Math.min(FEED_POSTS_MAX_OFFSET, Math.max(0, Math.trunc(n))) : 0;
}

export function parseFeedPaging(query: { offset?: unknown }): { offset: number } {
const blank = query.offset === null || query.offset === undefined || (typeof query.offset === "string" && query.offset.trim() === "");
return { offset: clampFeedOffset(blank ? NaN : Number(query.offset)) };
}

/** Memo key for a feed page. The offset is clamped first so `-5`, `1.9` and `9999999999` cannot each mint a distinct hot key. */
export function feedPostsKey(offset: number): string {
return `feed-posts:${clampFeedOffset(offset)}`;
}

/** Cursor for the next page: oldest id on a full page, else null (no more). */
export function nextPostsCursor(ids: number[], limit: number): number | null {
if (ids.length < limit) return null;
Expand Down
Loading
Loading