Skip to content
Open
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
14 changes: 11 additions & 3 deletions app/src/app/api/launch/meta/[a]/[b]/route.ts
Original file line number Diff line number Diff line change
@@ -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/<launcher>/<meta_key> — 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" } });
}
}
16 changes: 12 additions & 4 deletions app/src/app/api/launch/meta/[a]/route.ts
Original file line number Diff line number Diff line change
@@ -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> — 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" } });
}
}
52 changes: 52 additions & 0 deletions app/src/lib/launchpad/holdersServer.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
29 changes: 23 additions & 6 deletions app/src/lib/launchpad/holdersServer.ts
Original file line number Diff line number Diff line change
@@ -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[] };
Expand All @@ -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<HolderPanel | null> {
const db = maybeDb();
if (!db) return null;
Expand All @@ -29,19 +37,28 @@ export async function getHolderPanel(chain: ChainKey, token: string): Promise<Ho
const supply = BigInt(l.supply);
const system = systemAddresses(chain);
const excluded = [...system, ZERO_ADDR, DEAD_ADDR];
const [rows, swaps, pool] = await Promise.all([
const [rows, sniperRows, creatorFacts, pool] = await Promise.all([
db<{ holder: string; balance: string }[]>`
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()}`;
Expand Down
16 changes: 15 additions & 1 deletion app/src/lib/launchpad/metaShared.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down Expand Up @@ -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);
});
10 changes: 10 additions & 0 deletions app/src/lib/launchpad/metaShared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<token> and /<launcher>/<key> 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<MetaInput> & { meta_key?: string }): { ok: true; value: MetaInput } | { ok: false; error: string } {
const name = (m.name ?? "").trim();
const symbol = (m.symbol ?? "").trim().toUpperCase();
Expand Down
5 changes: 5 additions & 0 deletions app/src/lib/launchpad/token-market.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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), "—");
});
1 change: 1 addition & 0 deletions app/src/lib/launchpad/token-market.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
Loading