diff --git a/app/src/app/api/launch/meta/[a]/[b]/route.ts b/app/src/app/api/launch/meta/[a]/[b]/route.ts index a2961b63..674aa285 100644 --- a/app/src/app/api/launch/meta/[a]/[b]/route.ts +++ b/app/src/app/api/launch/meta/[a]/[b]/route.ts @@ -1,12 +1,20 @@ import { NextResponse } from "next/server"; import { readMeta } from "@/lib/launchpad/meta"; +import { isMetaKeyParam, isMetaTokenParam } from "@/lib/launchpad/metaShared"; export const dynamic = "force-dynamic"; /** GET /api/launch/meta// — the on-chain metadataURI form (stable while the salt search runs). */ export async function GET(_req: Request, ctx: { params: Promise<{ a: string; b: string }> }) { const { a, b } = await ctx.params; - const m = await readMeta({ launcher: a, key: b }); - if (!m) return NextResponse.json({ error: "not found" }, { status: 404 }); - return NextResponse.json(m, { headers: { "cache-control": "public, max-age=300" } }); + if (!isMetaTokenParam(a)) return NextResponse.json({ error: "bad launcher" }, { status: 400 }); + if (!isMetaKeyParam(b)) return NextResponse.json({ error: "bad key" }, { status: 400 }); + try { + const m = await readMeta({ launcher: a, key: b }); + if (!m) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json(m, { headers: { "cache-control": "public, max-age=300" } }); + } catch (err) { + console.error("[launch] meta failed:", err instanceof Error ? err.message : err); + return NextResponse.json({ error: "could not load metadata" }, { status: 502, headers: { "cache-control": "no-store" } }); + } } diff --git a/app/src/app/api/launch/meta/[a]/route.ts b/app/src/app/api/launch/meta/[a]/route.ts index 7921e0f4..4445394b 100644 --- a/app/src/app/api/launch/meta/[a]/route.ts +++ b/app/src/app/api/launch/meta/[a]/route.ts @@ -1,14 +1,22 @@ import { NextResponse } from "next/server"; -import { chainKeyOr } from "@/lib/chainPublic"; +import { chainKeyOr, isChainKey } from "@/lib/chainPublic"; import { readMeta } from "@/lib/launchpad/meta"; +import { isMetaTokenParam } from "@/lib/launchpad/metaShared"; export const dynamic = "force-dynamic"; /** GET /api/launch/meta/ — token metadata JSON (image, description, links). */ export async function GET(_req: Request, ctx: { params: Promise<{ a: string }> }) { const { a } = await ctx.params; + if (!isMetaTokenParam(a)) return NextResponse.json({ error: "bad token" }, { status: 400 }); const c = new URL(_req.url).searchParams.get("chain"); - const m = await readMeta({ token: a, chain: chainKeyOr(c, null) ?? undefined }); - if (!m) return NextResponse.json({ error: "not found" }, { status: 404 }); - return NextResponse.json(m, { headers: { "cache-control": "public, max-age=300" } }); + if (c !== null && !isChainKey(c)) return NextResponse.json({ error: "bad chain" }, { status: 400 }); + try { + const m = await readMeta({ token: a, chain: chainKeyOr(c, null) ?? undefined }); + if (!m) return NextResponse.json({ error: "not found" }, { status: 404 }); + return NextResponse.json(m, { headers: { "cache-control": "public, max-age=300" } }); + } catch (err) { + console.error("[launch] meta failed:", err instanceof Error ? err.message : err); + return NextResponse.json({ error: "could not load metadata" }, { status: 502, headers: { "cache-control": "no-store" } }); + } } diff --git a/app/src/lib/launchpad/holdersServer.test.ts b/app/src/lib/launchpad/holdersServer.test.ts new file mode 100644 index 00000000..b0713604 --- /dev/null +++ b/app/src/lib/launchpad/holdersServer.test.ts @@ -0,0 +1,52 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { creatorActivity, sniperSummary } from "./holders.ts"; + +const source = readFileSync(new URL("./holdersServer.ts", import.meta.url), "utf8"); +const panelSql = source.slice(source.indexOf("const [rows,"), source.indexOf("const lite =")); +const swapQueries = panelSql.split("db<").filter((q) => q.includes("FROM bb_launch_swaps")); + +test("holder panel never caps the swap rows it reads", () => { + assert.equal(swapQueries.length, 2, "sniper window + creator aggregate"); + for (const q of swapQueries) assert.doesNotMatch(q, /\bLIMIT\b/, "swap facts are complete, not a row window"); +}); + +test("sniper detection reads the complete launch window, not the first N swaps", () => { + const sniperSql = swapQueries.find((q) => q.includes("SNIPER_BLOCKS")); + assert.ok(sniperSql, "sniper query present"); + assert.match(sniperSql, /block_number BETWEEN/); + assert.match(sniperSql, /is_buy AND trader IS NOT NULL/); +}); + +test("creator totals are SQL aggregates, so a sale past 5,000 swaps still counts", () => { + const creatorSql = swapQueries.find((q) => q.includes("FILTER")); + assert.ok(creatorSql, "creator aggregate present"); + assert.match(creatorSql, /sum\(abs\(amount1\)\) FILTER \(WHERE is_buy\)/); + assert.match(creatorSql, /sum\(abs\(amount1\)\) FILTER \(WHERE NOT is_buy\)/); + assert.match(creatorSql, /count\(\*\) FILTER \(WHERE NOT is_buy\)/); +}); + +test("sniperSummary covers a qualifying buy in the 5,001st row", () => { + const launchBlock = 100n; + const sniper = "0x" + "aa".repeat(20); + const other = "0x" + "bb".repeat(20); + const swaps = Array.from({ length: 5001 }, (_, i) => ({ + trader: i === 5000 ? sniper : other, + is_buy: true, + block_number: 101n, + token_amount: 1n, + })); + const sn = sniperSummary(swaps, launchBlock, 50_000n); + assert.ok(sn.wallets.includes(sniper), "a launch-window buy is not lost behind a row cap"); +}); + +test("creatorActivity counts a sale past 5,000 swaps", () => { + const creator = "0x" + "cc".repeat(20); + const other = "0x" + "bb".repeat(20); + const swaps = [ + ...Array.from({ length: 5000 }, () => ({ trader: other, is_buy: true, block_number: 101n, token_amount: 1n })), + { trader: creator, is_buy: false, block_number: 9000n, token_amount: 7n }, + ]; + assert.deepEqual(creatorActivity(swaps, creator), { bought: 0n, sold: 7n, sells: 1 }); +}); diff --git a/app/src/lib/launchpad/holdersServer.ts b/app/src/lib/launchpad/holdersServer.ts index f788f1ba..bf09a98a 100644 --- a/app/src/lib/launchpad/holdersServer.ts +++ b/app/src/lib/launchpad/holdersServer.ts @@ -1,7 +1,7 @@ import "server-only"; import { maybeDb } from "@/lib/db"; import { chainIdOf, type ChainKey } from "@/lib/chainPublic"; -import { DEAD_ADDR, TOP_HOLDERS, ZERO_ADDR, creatorActivity, holderTags, shareBps, sniperSummary, trustNotes, type HolderTag } from "./holders"; +import { DEAD_ADDR, SNIPER_BLOCKS, TOP_HOLDERS, ZERO_ADDR, holderTags, shareBps, sniperSummary, trustNotes, type HolderTag } from "./holders"; import { systemAddresses } from "./indexer"; export type HolderRow = { address: string; balance: string; bps: number; tags: HolderTag[] }; @@ -17,7 +17,15 @@ export type HolderPanel = { notes: { level: "warn" | "info" | "good"; text: string }[]; }; -/** Holder distribution + creator / sniper facts for one launch. Cheap: two indexed queries + one small scan of its swaps. */ +/** + * Holder distribution + creator / sniper facts for one launch. + * + * No query scans the token's whole swap history: sniper detection reads only + * the launch block … +SNIPER_BLOCKS window (the only rows it can use), and + * creator totals are aggregated by Postgres. A row LIMIT cannot substitute + * for either — it would silently hide a sale past the cap ("never sold") or + * drop a qualifying launch-window buy. + */ export async function getHolderPanel(chain: ChainKey, token: string): Promise { const db = maybeDb(); if (!db) return null; @@ -29,19 +37,28 @@ export async function getHolderPanel(chain: ChainKey, token: string): Promise` SELECT h.holder, h.balance::text AS balance FROM bb_token_holders h WHERE h.chain_id = ${cid} AND h.token = ${t} AND h.balance > 0 AND NOT (h.holder = ANY(${excluded})) ORDER BY h.balance DESC LIMIT ${TOP_HOLDERS}`, // qualified: a bare "balance" would sort the ::text alias + // Complete sniper window: every buy in the launch block … +SNIPER_BLOCKS, never a row cap. db<{ trader: string | null; is_buy: boolean; block_number: bigint; amount1: string }[]>` - SELECT trader, is_buy, block_number, amount1::text AS amount1 FROM bb_launch_swaps WHERE chain_id = ${cid} AND token = ${t}`, + SELECT trader, is_buy, block_number, amount1::text AS amount1 FROM bb_launch_swaps + WHERE chain_id = ${cid} AND token = ${t} AND is_buy AND trader IS NOT NULL + AND block_number BETWEEN ${l.block_number} AND ${l.block_number} + ${SNIPER_BLOCKS}`, + // Complete creator totals: aggregate in Postgres instead of materializing swaps (a sale past any row cap would read as "never sold"). + db<{ bought: string; sold: string; sells: number }[]>` + SELECT COALESCE(sum(abs(amount1)) FILTER (WHERE is_buy), 0)::text AS bought, + COALESCE(sum(abs(amount1)) FILTER (WHERE NOT is_buy), 0)::text AS sold, + count(*) FILTER (WHERE NOT is_buy)::int AS sells + FROM bb_launch_swaps WHERE chain_id = ${cid} AND token = ${t} AND trader = ${l.launcher.toLowerCase()}`, db<{ balance: string }[]>` SELECT COALESCE(sum(balance), 0)::text AS balance FROM bb_token_holders WHERE chain_id = ${cid} AND token = ${t} AND holder = ANY(${system})`, ]); - const lite = swaps.map((s) => ({ trader: s.trader, is_buy: s.is_buy, block_number: s.block_number, token_amount: BigInt(s.amount1) })); + const lite = sniperRows.map((s) => ({ trader: s.trader, is_buy: s.is_buy, block_number: s.block_number, token_amount: BigInt(s.amount1) })); const sn = sniperSummary(lite, l.block_number, supply); - const creator = creatorActivity(lite, l.launcher); + const creator = { bought: BigInt(creatorFacts[0]?.bought ?? "0"), sold: BigInt(creatorFacts[0]?.sold ?? "0"), sells: creatorFacts[0]?.sells ?? 0 }; const ctx = { launcher: l.launcher, system, snipers: new Set(sn.wallets) }; const top = rows.map((r) => ({ address: r.holder, balance: r.balance, bps: shareBps(BigInt(r.balance), supply), tags: holderTags(r.holder, BigInt(r.balance), supply, ctx) })); const [creatorRow] = await db<{ balance: string }[]>`SELECT balance::text AS balance FROM bb_token_holders WHERE chain_id = ${cid} AND token = ${t} AND holder = ${l.launcher.toLowerCase()}`; diff --git a/app/src/lib/launchpad/metaShared.test.ts b/app/src/lib/launchpad/metaShared.test.ts index 9064d8ea..c8a0162f 100644 --- a/app/src/lib/launchpad/metaShared.test.ts +++ b/app/src/lib/launchpad/metaShared.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { metaUriFor, metaWriteDecision, validateMeta } from "./metaShared.ts"; +import { isMetaKeyParam, isMetaTokenParam, metaUriFor, metaWriteDecision, validateMeta } from "./metaShared.ts"; const L = "0x00000000000000000000000000000000000c0ffe"; const SALT = "0x" + "a".repeat(64); @@ -76,3 +76,17 @@ test("metaWriteDecision: a different banner is a conflict, the same one is an id assert.equal(metaWriteDecision({ ...row, banner_url: "https://cdn.example/other.webp" }, v.value), "conflict"); assert.equal(metaWriteDecision({ ...row, banner_url: null }, v.value), "conflict"); }); + +test("meta GET params: addresses and bytes32 keys pass, junk is rejected before SQL", () => { + assert.equal(isMetaTokenParam(L), true); + assert.equal(isMetaTokenParam(L.toLowerCase()), true); + assert.equal(isMetaTokenParam("0x1234"), false); + assert.equal(isMetaTokenParam("not-an-address"), false); + assert.equal(isMetaTokenParam(""), false); + assert.equal(isMetaTokenParam(null), false); + assert.equal(isMetaKeyParam(SALT), true); + assert.equal(isMetaKeyParam(KEY.toLowerCase()), true); + assert.equal(isMetaKeyParam("0x1234"), false); + assert.equal(isMetaKeyParam(L), false, "an address is not a bytes32 key"); + assert.equal(isMetaKeyParam(""), false); +}); diff --git a/app/src/lib/launchpad/metaShared.ts b/app/src/lib/launchpad/metaShared.ts index 9d8ac106..cf4c48e6 100644 --- a/app/src/lib/launchpad/metaShared.ts +++ b/app/src/lib/launchpad/metaShared.ts @@ -16,6 +16,16 @@ export type MetaInput = { chain: ChainKey; launcher: string; salt: Hex; meta_key export const LIMITS = { name: 32, symbol: 10, description: 280 } as const; const BYTES32 = /^0x[0-9a-fA-F]{64}$/; +/** GET /api/launch/meta/ and // params: reject before SQL, never after. */ +export function isMetaTokenParam(v: unknown): boolean { + return typeof v === "string" && isAddress(v); +} + +/** A meta_key is always a bytes32 hex string (see validateMeta). */ +export function isMetaKeyParam(v: unknown): v is Hex { + return typeof v === "string" && BYTES32.test(v); +} + export function validateMeta(m: Partial & { meta_key?: string }): { ok: true; value: MetaInput } | { ok: false; error: string } { const name = (m.name ?? "").trim(); const symbol = (m.symbol ?? "").trim().toUpperCase(); diff --git a/app/src/lib/launchpad/token-market.test.ts b/app/src/lib/launchpad/token-market.test.ts index c006c405..d2fd836f 100644 --- a/app/src/lib/launchpad/token-market.test.ts +++ b/app/src/lib/launchpad/token-market.test.ts @@ -20,3 +20,8 @@ test("trade counts stay integral while large values stay compact", () => { assert.equal(marketCount(3), "3"); assert.equal(marketCount(12500), "12.5K"); }); +test("trade counts never render NaN or Infinity", () => { + assert.equal(marketCount(NaN), "—"); + assert.equal(marketCount(Infinity), "—"); + assert.equal(marketCount(-Infinity), "—"); +}); diff --git a/app/src/lib/launchpad/token-market.ts b/app/src/lib/launchpad/token-market.ts index 43fe7411..3c45daa4 100644 --- a/app/src/lib/launchpad/token-market.ts +++ b/app/src/lib/launchpad/token-market.ts @@ -8,5 +8,6 @@ export function holderFactsAvailable(panel: { synced: boolean } | null): boolean } export function marketCount(value: number): string { + if (!Number.isFinite(value)) return "—"; return value.toLocaleString("en-US", { notation: "compact", maximumFractionDigits: 1 }); }