From 4613f4786643e37fac8b6b3864b272f9889fb4b1 Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Thu, 8 Oct 2026 14:16:55 +0530 Subject: [PATCH 1/2] =?UTF-8?q?fix(api):=20harden=20launch=20read=20paths?= =?UTF-8?q?=20=E2=80=94=20meta=20error=20shape,=20bounded=20holder=20swaps?= =?UTF-8?q?,=20finite=20count=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Meta GETs were the only JSON routes without try/catch: a DB blip returned HTML 500s on the wallet/factory metadata path, and unvalidated params reached SQL. Now both routes return 400 on bad launcher/token/key/chain and JSON 502s with no-store on failure, matching holders/list/search. getHolderPanel scanned the whole bb_launch_swaps table per cache miss (100k rows + BigInt parses on hot tokens). It now reads the earliest HOLDER_SWAPS_LIMIT=5000 swaps oldest-first, keeping sniper detection exact while staying bounded like every other swaps read. marketCount was the one formatter missing the finite guard its siblings have (fmtCompact/fmtEth/marketUsd): NaN painted as 'NaN' and Infinity as '∞' in Buys-and-sells. Now returns '—'. Tests: token-market non-finite, meta GET param validators, holder-window bound + oldest-first sniper invariant. Verified: npm test 1049 pass / 0 fail, typecheck + eslint clean. --- app/src/app/api/launch/meta/[a]/[b]/route.ts | 14 ++++++-- app/src/app/api/launch/meta/[a]/route.ts | 16 ++++++--- app/src/lib/launchpad/holdersServer.test.ts | 35 ++++++++++++++++++++ app/src/lib/launchpad/holdersServer.ts | 15 +++++++-- app/src/lib/launchpad/metaShared.test.ts | 16 ++++++++- app/src/lib/launchpad/metaShared.ts | 10 ++++++ app/src/lib/launchpad/token-market.test.ts | 5 +++ app/src/lib/launchpad/token-market.ts | 1 + 8 files changed, 102 insertions(+), 10 deletions(-) create mode 100644 app/src/lib/launchpad/holdersServer.test.ts 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..01f73349 --- /dev/null +++ b/app/src/lib/launchpad/holdersServer.test.ts @@ -0,0 +1,35 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { HOLDER_SWAPS_LIMIT } from "./holdersServer.ts"; +import { creatorActivity, sniperSummary } from "./holders.ts"; + +test("holder panel scans a bounded oldest-first window, never the whole swaps table", () => { + assert.ok(Number.isInteger(HOLDER_SWAPS_LIMIT), "limit is an integer"); + assert.ok(HOLDER_SWAPS_LIMIT > 0 && HOLDER_SWAPS_LIMIT <= 5000, `limit stays bounded (got ${HOLDER_SWAPS_LIMIT})`); +}); + +test("oldest-first truncation keeps sniper detection exact while bounding late tail work", () => { + const launchBlock = 100n; + const sniper = "0x" + "aa".repeat(20); + const late = "0x" + "bb".repeat(20); + const swaps = [ + { trader: sniper, is_buy: true, block_number: 101n, token_amount: 50n }, + { trader: late, is_buy: true, block_number: 10_000n, token_amount: 70n }, + ]; + const full = sniperSummary(swaps, launchBlock, 1000n); + assert.deepEqual(full.wallets, [sniper.toLowerCase()]); + // oldest-first LIMIT 1 keeps the sniper-window swap; newest-first would keep only the late one + const oldestFirst = sniperSummary(swaps.slice(0, 1), launchBlock, 1000n); + assert.deepEqual(oldestFirst.wallets, [sniper.toLowerCase()]); + const creator = "0x" + "cc".repeat(20); + const activity = creatorActivity( + [ + { trader: creator, is_buy: true, block_number: 101n, token_amount: 10n }, + { trader: creator, is_buy: false, block_number: 102n, token_amount: 4n }, + ], + creator, + ); + assert.equal(activity.bought, 10n); + assert.equal(activity.sold, 4n); + assert.equal(activity.sells, 1); +}); diff --git a/app/src/lib/launchpad/holdersServer.ts b/app/src/lib/launchpad/holdersServer.ts index f788f1ba..0caf8305 100644 --- a/app/src/lib/launchpad/holdersServer.ts +++ b/app/src/lib/launchpad/holdersServer.ts @@ -17,7 +17,17 @@ 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. */ +/** + * How many swaps the holder panel scans. Sniper detection only needs the + * earliest swaps (launch block … +SNIPER_BLOCKS), so oldest-first keeps it + * exact while the table stays bounded: without a LIMIT one hot token + * materializes every swap per cache miss (100k rows → 100k BigInt parses). + * Creator totals on hyper-traded tokens are a lower bound past this cap; + * the panel stays available instead of OOMing. + */ +export const HOLDER_SWAPS_LIMIT = 5000; + +/** Holder distribution + creator / sniper facts for one launch. Cheap: two indexed queries + one bounded scan of its earliest swaps. */ export async function getHolderPanel(chain: ChainKey, token: string): Promise { const db = maybeDb(); if (!db) return null; @@ -35,7 +45,8 @@ export async function getHolderPanel(chain: ChainKey, token: string): Promise 0 AND NOT (h.holder = ANY(${excluded})) ORDER BY h.balance DESC LIMIT ${TOP_HOLDERS}`, // qualified: a bare "balance" would sort the ::text alias 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} + ORDER BY block_number ASC LIMIT ${HOLDER_SWAPS_LIMIT}`, 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})`, ]); 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 }); } From bf85274a5a6647366f566c44aa6f5f8ee16c1091 Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Tue, 20 Oct 2026 15:07:23 +0530 Subject: [PATCH 2/2] fix(holders): complete creator and sniper facts, no swap row cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the 5000-row swap LIMIT could hide a creator sale (panel still said 'never sold') and truncate the launch-block+3 sniper window on a busy launch. - Sniper detection now reads exactly the launch block … +SNIPER_BLOCKS window (every buy, no LIMIT) instead of the first 5000 swaps. - Creator totals (bought/sold/sells) are Postgres aggregates (sum(abs(amount1)) FILTER …, count(*) FILTER …), so a sale past any row cap still counts and no swap history is materialized. - Tests: query-contract checks (no LIMIT on swap facts, windowed sniper query, SQL aggregates) plus 5,001-row regressions for sniperSummary and creatorActivity. --- app/src/lib/launchpad/holdersServer.test.ts | 65 +++++++++++++-------- app/src/lib/launchpad/holdersServer.ts | 36 +++++++----- 2 files changed, 62 insertions(+), 39 deletions(-) diff --git a/app/src/lib/launchpad/holdersServer.test.ts b/app/src/lib/launchpad/holdersServer.test.ts index 01f73349..b0713604 100644 --- a/app/src/lib/launchpad/holdersServer.test.ts +++ b/app/src/lib/launchpad/holdersServer.test.ts @@ -1,35 +1,52 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { HOLDER_SWAPS_LIMIT } from "./holdersServer.ts"; +import { readFileSync } from "node:fs"; import { creatorActivity, sniperSummary } from "./holders.ts"; -test("holder panel scans a bounded oldest-first window, never the whole swaps table", () => { - assert.ok(Number.isInteger(HOLDER_SWAPS_LIMIT), "limit is an integer"); - assert.ok(HOLDER_SWAPS_LIMIT > 0 && HOLDER_SWAPS_LIMIT <= 5000, `limit stays bounded (got ${HOLDER_SWAPS_LIMIT})`); +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("oldest-first truncation keeps sniper detection exact while bounding late tail work", () => { +test("sniperSummary covers a qualifying buy in the 5,001st row", () => { const launchBlock = 100n; const sniper = "0x" + "aa".repeat(20); - const late = "0x" + "bb".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 = [ - { trader: sniper, is_buy: true, block_number: 101n, token_amount: 50n }, - { trader: late, is_buy: true, block_number: 10_000n, token_amount: 70n }, + ...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 }, ]; - const full = sniperSummary(swaps, launchBlock, 1000n); - assert.deepEqual(full.wallets, [sniper.toLowerCase()]); - // oldest-first LIMIT 1 keeps the sniper-window swap; newest-first would keep only the late one - const oldestFirst = sniperSummary(swaps.slice(0, 1), launchBlock, 1000n); - assert.deepEqual(oldestFirst.wallets, [sniper.toLowerCase()]); - const creator = "0x" + "cc".repeat(20); - const activity = creatorActivity( - [ - { trader: creator, is_buy: true, block_number: 101n, token_amount: 10n }, - { trader: creator, is_buy: false, block_number: 102n, token_amount: 4n }, - ], - creator, - ); - assert.equal(activity.bought, 10n); - assert.equal(activity.sold, 4n); - assert.equal(activity.sells, 1); + 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 0caf8305..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[] }; @@ -18,16 +18,14 @@ export type HolderPanel = { }; /** - * How many swaps the holder panel scans. Sniper detection only needs the - * earliest swaps (launch block … +SNIPER_BLOCKS), so oldest-first keeps it - * exact while the table stays bounded: without a LIMIT one hot token - * materializes every swap per cache miss (100k rows → 100k BigInt parses). - * Creator totals on hyper-traded tokens are a lower bound past this cap; - * the panel stays available instead of OOMing. + * 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 const HOLDER_SWAPS_LIMIT = 5000; - -/** Holder distribution + creator / sniper facts for one launch. Cheap: two indexed queries + one bounded scan of its earliest swaps. */ export async function getHolderPanel(chain: ChainKey, token: string): Promise { const db = maybeDb(); if (!db) return null; @@ -39,20 +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} - ORDER BY block_number ASC LIMIT ${HOLDER_SWAPS_LIMIT}`, + 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()}`;