diff --git a/app/scripts/mcap-check.mts b/app/scripts/mcap-check.mts new file mode 100644 index 00000000..aa355031 --- /dev/null +++ b/app/scripts/mcap-check.mts @@ -0,0 +1,147 @@ +/** Actual read-model/API/SQL checks. Requires an empty, disposable localhost PostgreSQL database. */ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { performance } from "node:perf_hooks"; + +const url = process.env.MCAP_TEST_DATABASE_URL; +if (!url || new URL(url).hostname !== "127.0.0.1") throw new Error("Set MCAP_TEST_DATABASE_URL to an empty disposable database on 127.0.0.1"); +process.env.DATABASE_URL = url; +const stock = "0x000000000000000000000000000000000000a001"; +const unlisted = "0x000000000000000000000000000000000000a002"; +const savedFetch = globalThis.fetch; +// Deterministic quote feeds; the database and production query are real. +globalThis.fetch = async (input) => { + const u = String(input); + if (u.includes("coinbase.com")) return Response.json({ data: { amount: "2500" } }); + if (u.includes("/rhj/assets")) return Response.json([{ tokenSymbol: "TEST", tokenName: "Test stock", tokenDecimals: 6, currentMultiplier: "1", status: "ASSET_STATUS_ACTIVE", deployments: [{ contractAddress: stock, chainId: 4663 }] }]); + if (u.includes("/rhj/prices/")) return Response.json({ quotes: [{ tokenSymbol: "TEST", bid: "200", ask: "200" }] }); + throw new Error(`Unexpected fetch in market-cap test: ${u}`); +}; +const [{ db }, { publicClient }, { listLaunchesPage, getLaunch }, { startTickForFdv }, { classifyMcap, MCAP_POLICY_VERSION }, { clearMemo }, { mcapPageQuery }] = await Promise.all([ + import("../src/lib/db"), import("../src/lib/chain"), import("../src/lib/launchpad/queries"), import("../src/lib/launchpad/math"), import("../src/lib/launchpad/mcap-policy"), import("../src/lib/launchpad/memo"), import("../src/lib/launchpad/mcap-query"), +]); +publicClient("base").readContract = (async () => { throw new Error("No on-chain price feed in this test"); }) as ReturnType["readContract"]; +const sql = db(); +const creator = `0x${"c".repeat(40)}`; +/** Derive a deterministic address so fixtures can reuse a wallet or deliberately use distinct wallets. */ +const wallet = (id: number) => `0x${id.toString(16).padStart(40, "0")}`; +let seq = 0; +const launches = new Map(); +/** Insert a launch with configurable FDVs and quote units, retaining its identity for subsequent swap fixtures. */ +async function launch(name: string, args: { chain?: number; quote?: string; decimals?: number; quoteUsd?: number; start?: number; current?: number; time?: string; sqrt?: boolean } = {}) { + const id = ++seq, chain = args.chain ?? 5042, decimals = args.decimals ?? 6, quoteUsd = args.quoteUsd ?? 1; + const start = args.start ?? 25_000, current = args.current ?? start; + const rawRatio = 1e9 / (current / quoteUsd) * 10 ** (18 - decimals); + const sqrt = BigInt(Math.floor(Math.sqrt(rawRatio) * 2 ** 96)).toString(); + const row = { token: wallet(1000 + id), chain, decimals, quoteUsd, block: 100 }; + await sql`INSERT INTO bb_launches (chain_id,token,token_id,launcher,quote,pool_id,start_tick,lp_fee,supply,name,symbol,block_number,block_time,tx_hash,log_index,sqrt_price_x96,tick) + VALUES (${chain},${row.token},${id},${creator},${args.quote ?? "0x3600000000000000000000000000000000000000"},${`0x${id.toString(16).padStart(64,"0")}`},${startTickForFdv(start / quoteUsd, decimals)},0,'1000000000000000000000000000',${name},${name.slice(0,8)},100,${args.time ?? "2026-01-01T00:00:00Z"},${`0x${id.toString(16).padStart(64,"0")}`},0,${args.sqrt === false ? null : sqrt},${Math.floor(Math.log(rawRatio)/Math.log(1.0001))})`; + launches.set(name, row); +} +/** Record a USD-sized swap in the launch's raw quote units and update its indexed lifetime volume. */ +async function swap(name: string, dollars: number, who: string | null, block = 104) { + const row = launches.get(name)!; + const amount = BigInt(Math.round(dollars / row.quoteUsd * 10 ** row.decimals)); + const id = ++seq; + await sql`INSERT INTO bb_launch_swaps (chain_id,tx_hash,log_index,token,pool_id,trader,amount0,amount1,sqrt_price_x96,tick,is_buy,block_number,block_time) + SELECT chain_id,${`0x${(10000+id).toString(16).padStart(64,"0")}`},0,token,pool_id,${who},${(-amount).toString()},1,COALESCE(sqrt_price_x96,1),COALESCE(tick,start_tick),true,${block},'2026-02-01T00:00:00Z' FROM bb_launches WHERE chain_id=${row.chain} AND token=${row.token}`; + await sql`UPDATE bb_launches SET volume_quote=volume_quote+${amount.toString()}::numeric,buys=buys+1 WHERE chain_id=${row.chain} AND token=${row.token}`; +} +/** Fetch the real read model using deterministic market-cap defaults, with overrides for individual checks. */ +const page = (opts: Parameters[0] = {}) => listLaunchesPage({ sort: "mcap", limit: 200, ethUsd: 2500, ...opts }); +/** Extract names in result order to compare filtering and pagination against the same fixture identities. */ +const names = (p: Awaited>) => p.items.map((r) => r.name); +try { + const schema = await readFile(new URL("../db/schema.sql", import.meta.url), "utf8"); + await sql.begin((tx) => tx.unsafe(schema)); + assert.equal((await sql`SELECT count(*) AS n FROM bb_launches`)[0].n, 0n, "use an empty test database"); + assert.throws(() => mcapPageQuery(sql, { where: sql``, quoteDecimals: sql`6`, quoteUsd: sql`1`, mode: "filtered", limit: 40, offset: 0, select: "SELECT * FROM bb_launches l" }), /unexpected SELECT prefix/, "a changed hydration SELECT must not silently drop market-cap facts"); + await launch("ArcMOON", { start: 1e35 }); await swap("ArcMOON", 0.02, creator); + await launch("DOTTIE", { chain: 4663, quote: wallet(0), decimals: 18, quoteUsd: 2500, start: 5000, current: 25_700 }); + await launch("Small zero-volume", { start: 5000 }); + await launch("Grew from small", { start: 25_000, current: 50_000_000 }); + await launch("Fell to small", { start: 50_000_000, current: 25_000 }); + for (const name of ["Supported", "Cumulative wallet", "Creator only", "Snipers", "Two wallets", "Dust third", "Unknown trader"]) await launch(name, { start: 20_000_000 }); + for (const id of [1,2,3]) { + await swap("Supported", 10_000, wallet(id)); + await swap("Creator only", 100_000, creator); + await swap("Snipers", 10_000, wallet(id), 103); + } + await swap("Two wallets", 100_000, wallet(1)); await swap("Two wallets", 1, wallet(2)); + await swap("Cumulative wallet", 100_000, wallet(1)); await swap("Cumulative wallet", 1, wallet(2)); + await swap("Cumulative wallet", 0.6, wallet(3)); await swap("Cumulative wallet", 0.4, wallet(3)); + await swap("Dust third", 100_000, wallet(1)); await swap("Dust third", 1, wallet(2)); await swap("Dust third", 0.99, wallet(3)); + await swap("Unknown trader", 100_000, null); + await launch("Stock six decimals", { chain: 4663, quote: stock, quoteUsd: 200, start: 12_345 }); + await launch("Stock wrong chain", { quote: stock, start: 12_345 }); + await launch("Unlisted six decimals", { quote: unlisted, start: 12_345 }); + await sql`INSERT INTO bb_quote_tokens (chain_id,address,decimals,symbol,name) VALUES (5042,${unlisted},6,'MEME','Unlisted')`; + await launch("Arc native", { quote: wallet(0), decimals: 18, start: 10_000 }); + await launch("Older higher sqrt", { current: 20_000.0002 }); + await launch("Newer lower sqrt", { current: 20_000.0001, time: "2026-01-02T00:00:00Z" }); + await launch("Tie A", { current: 12_000 }); await launch("Tie B", { current: 12_000 }); + await launch("No swaps fallback", { start: 8000, sqrt: false }); + + const all = await page({ mcap: "all" }); + const filtered = await page(); + const hidden = ["ArcMOON", "Creator only", "Snipers", "Two wallets", "Dust third", "Unknown trader"]; + assert.deepEqual(names(filtered), names(all).filter((name) => !hidden.includes(name))); + for (const name of ["DOTTIE", "Small zero-volume", "Supported", "Grew from small", "Fell to small"]) assert.ok(names(filtered).includes(name), name); + assert.equal(all.items.find((r) => r.name === "Supported")!.traders_24h_ex, 0, "quiet lifetime history still qualifies"); + for (const row of all.items) assert.equal(row.mcap!.classification, classifyMcap(row.mcap!), "SQL and policy agree"); + assert.equal(all.items.find((r) => r.name === "Dust third")!.mcap!.outsideTraders, 2); + assert.equal(all.items.find((r) => r.name === "Cumulative wallet")!.mcap!.outsideTraders, 3, "wallet minimum is cumulative, not per trade"); + assert.equal(all.items.find((r) => r.name === "Small zero-volume")!.mcap!.outsideVolumeQuote, null, "unassessed is not zero activity"); + const priced = filtered.items.filter((r) => r.fdv_usd !== null); + for (let i=1;i= priced[i].fdv_usd!, "displayed USD valuations descend"); + assert.ok(names(all).indexOf("Older higher sqrt") < names(all).indexOf("Newer lower sqrt"), "sqrt price beats a same-tick age tie"); + assert.ok(names(all).indexOf("Tie A") < names(all).indexOf("Tie B"), "token breaks exact ties"); + const unknown = all.items.filter((r) => r.fdv_usd === null); + assert.deepEqual(new Set(unknown.map((r) => r.name)), new Set(["Stock wrong chain", "Unlisted six decimals"])); + assert.deepEqual(all.items.slice(-unknown.length), unknown, "unknown USD valuations sort last"); + assert.equal(all.items.find((r) => r.name === "Unlisted six decimals")!.quote_decimals, 6); + assert.ok(Math.abs(all.items.find((r) => r.name === "Stock six decimals")!.fdv_usd! - 12_345) < 1e-7); + assert.ok(Math.abs(all.items.find((r) => r.name === "Arc native")!.fdv_usd! - 10_000) < 1e-7); + assert.ok(all.items.find((r) => r.name === "No swaps fallback")!.fdv_usd! > 8000); + const paged = []; + for (let offset=0;offsetr.token)); + assert.equal(p.hasMore, offset + 2 < filtered.items.length); + } + assert.deepEqual(paged, filtered.items.map((r)=>r.token), "filtering precedes pagination without gaps or duplicates"); + assert.ok((await page({ chain: "arc" })).items.every((r)=>r.chain==="arc")); + assert.ok((await page({ filter: "usdc" })).items.every((r)=>r.quote_key==="usdc")); + assert.equal((await page({ filter: "today" })).items.length, 0); + assert.ok(names(await page({ sort: "new" })).includes("ArcMOON"), "other sorts retain outliers"); + assert.ok(await getLaunch("arc", launches.get("ArcMOON")!.token, 2500), "direct links retain outliers"); + const [normal, doubled, missing] = await Promise.all([page(),page({ethUsd:5000}),page({ethUsd:null})]); + assert.equal(normal.items.find((r)=>r.name==="DOTTIE")!.quote_usd,2500); + assert.equal(doubled.items.find((r)=>r.name==="DOTTIE")!.quote_usd,5000); + assert.equal(missing.items.find((r)=>r.name==="DOTTIE")!.fdv_usd,null, "no ETH=$1 fallback"); + + const { GET } = await import("../src/app/api/launch/list/route"); + const defaultResponse = await GET(new Request("http://localhost/api/launch/list?sort=mcap&limit=200")); + assert.equal(defaultResponse.status, 200); + const defaultData = await defaultResponse.json(); + assert.equal(defaultData.mcap, "filtered"); + assert.deepEqual(defaultData.launches.map((r: { name: string }) => r.name), names(filtered)); + for (const mode of ["filtered","all"] as const) { + const response=await GET(new Request(`http://localhost/api/launch/list?sort=mcap&mcap=${mode}&limit=200`)); + assert.equal(response.status,200); const data=await response.json(); + assert.equal(data.mcap,mode); assert.equal(data.mcap_policy,MCAP_POLICY_VERSION); + assert.deepEqual(data.launches.map((r:{name:string})=>r.name),names(mode==="all"?all:filtered)); + } + clearMemo(); + const { GET: live } = await import("../src/app/api/launch/live/route"); + for (const mode of ["all","filtered"] as const) { + const response=await live(new Request(`http://localhost/api/launch/live?sort=mcap&mcap=${mode}&limit=200`)); + assert.equal(response.status,200); const data=await response.json(); + assert.equal(data.mcap,mode); + assert.deepEqual(data.launches.map((r:{name:string})=>r.name),names(mode==="all"?all:filtered),"live cache keys isolate modes"); + } + const before=performance.now(); await page(); + console.log(`market-cap: actual SQL, classification, lifetime history, decimal/chain pricing, sqrt ordering, pagination, concurrent requests and list/live APIs passed (${(performance.now()-before).toFixed(1)}ms warm list, ${all.items.length} fixtures)`); +} finally { + globalThis.fetch=savedFetch; + await sql.end(); +} diff --git a/app/src/app/(home)/page.tsx b/app/src/app/(home)/page.tsx index a9f2737f..b606bc7c 100644 --- a/app/src/app/(home)/page.tsx +++ b/app/src/app/(home)/page.tsx @@ -19,6 +19,7 @@ import LaunchPill from "@/components/launchpad/LaunchPill"; import WhyFree from "@/components/launchpad/WhyFree"; import { MagicCard } from "@/components/vendor/magic-card"; import { LAYOUT_COOKIE, parseLayout } from "@/lib/launchpad/list-layout"; +import { parseMcapMode } from "@/lib/launchpad/mcap-policy"; export const dynamic = "force-dynamic"; // Title and description come from the root layout; ?sort= / ?chain= / ?window= / ?view= are all this one page. @@ -27,15 +28,17 @@ export const metadata: Metadata = { alternates: { canonical: "/" } }; /** The river starts from the newest events (getLaunchFeed's ceiling) and keeps the ones from the last 30 minutes. */ const RIVER_SEED = 100; -export default async function Home({ searchParams }: { searchParams: Promise<{ sort?: string; window?: string; chain?: string; filter?: string; view?: string }> }) { +/** Render the home page with a URL-selected launch list, its valuation mode, and initial live activity. */ +export default async function Home({ searchParams }: { searchParams: Promise<{ sort?: string; window?: string; chain?: string; filter?: string; view?: string; mcap?: string }> }) { const sp = await searchParams; const sort = parseSort(sp.sort, "live"); + const mcap = parseMcapMode(sp.mcap); const window: VolumeWindow = VOLUME_WINDOWS.includes(sp.window as VolumeWindow) ? (sp.window as VolumeWindow) : "all"; const chain = visibleChainOr(sp.chain); const filter = isFilter(sp.filter) ? sp.filter : null; const usd = await ethUsd(); const layout = parseLayout((await cookies()).get(LAYOUT_COOKIE)?.value); - const listOpts = { sort, window, chain, filter, limit: PAGE_SIZE, ethUsd: usd }; + const listOpts = { sort, window, chain, filter, mcap, limit: PAGE_SIZE, ethUsd: usd }; const [page, feed, fresh, posts, fetchedTrending] = await Promise.all([ listLaunchesPage(listOpts), getLaunchFeed(RIVER_SEED, usd), @@ -54,7 +57,7 @@ export default async function Home({ searchParams }: { searchParams: Promise<{ s item.kind === "swap").map(feedKey)} /> inRiverWindow(item, now))} recent={feed} serverNow={now} coveredSince={riverCoverage(feed, RIVER_SEED, now)} lastActivityAt={feed[0] ? new Date(feed[0].at).getTime() : 0} />
- +
{/* buys against sells, flush with the bottom edge; no trades draws an empty track, never an even split */}
diff --git a/app/src/components/launchpad/LaunchList.tsx b/app/src/components/launchpad/LaunchList.tsx index 286f4f9b..a6a025df 100644 --- a/app/src/components/launchpad/LaunchList.tsx +++ b/app/src/components/launchpad/LaunchList.tsx @@ -19,7 +19,8 @@ import type { LaunchRow as L, LaunchSort, VolumeWindow } from "@/lib/launchpad/q import { CHAIN_SHORT, type ChainKey } from "@/lib/chainPublic"; import { VISIBLE_CHAINS } from "@/lib/launchpad/config"; import { FILTERS, filterOnChain, isAddressQuery, matchesFilter, matchesQuery, normalizeQuery, rankHit, type LaunchFilter } from "@/lib/launchpad/search"; -import { launchKey, mergeLaunches, refreshInPlace } from "@/lib/launchpad/list-state"; +import { launchKey, matchesListSelection, mergeLaunches, refreshInPlace } from "@/lib/launchpad/list-state"; +import { MCAP_FILTER_DESCRIPTION, type McapMode } from "@/lib/launchpad/mcap-policy"; import { liveChip, liveChipParts, liveTier } from "@/lib/launchpad/ranking"; import { PAGE_SIZE } from "@/lib/launchpad/paging"; import { Spinner } from "@/components/Skeleton"; @@ -45,7 +46,7 @@ const REORDER_QUIET_MS = 3_000; /** When the live order changes, rows and cards glide to their new places instead of jumping. */ const REORDER = { type: "spring", stiffness: 380, damping: 38 } as const; -type Selection = { sort: LaunchSort; window: VolumeWindow; chain: ChainKey | null; filter: LaunchFilter | null }; +type Selection = { sort: LaunchSort; window: VolumeWindow; chain: ChainKey | null; filter: LaunchFilter | null; mcap: McapMode }; type SearchResult = { query: string; chain: ChainKey | null; rows: L[]; error?: boolean }; export type ListView = "market" | "watchlist"; @@ -53,11 +54,11 @@ export type ListView = "market" | "watchlist"; * Shared live data, stable pointer targets, URL-backed filters and scoped async results. `serverNow` is the time the * page rendered: the clock starts there, so hydration sees the same tiers, filters and divider as the server's HTML. */ -export default function LaunchList({ initial, initialHasMore = false, initialSort, initialWindow, initialChain, initialFilter = null, initialView = "market", initialLayout = "list", frame = "rounded-2xl border border-line bg-paper", bare = false, hasDb, serverNow }: { initial: L[]; serverNow: number; frame?: string; bare?: boolean; initialHasMore?: boolean; initialSort: LaunchSort; initialWindow: VolumeWindow; initialChain: ChainKey | null; initialFilter?: LaunchFilter | null; initialView?: ListView; initialLayout?: ListLayout; ethUsd?: number | null; hasDb: boolean }) { +export default function LaunchList({ initial, initialHasMore = false, initialSort, initialWindow, initialChain, initialFilter = null, initialMcap = "filtered", initialView = "market", initialLayout = "list", frame = "rounded-2xl border border-line bg-paper", bare = false, hasDb, serverNow }: { initial: L[]; serverNow: number; frame?: string; bare?: boolean; initialHasMore?: boolean; initialSort: LaunchSort; initialWindow: VolumeWindow; initialChain: ChainKey | null; initialFilter?: LaunchFilter | null; initialMcap?: McapMode; initialView?: ListView; initialLayout?: ListLayout; ethUsd?: number | null; hasDb: boolean }) { const router = useRouter(); const { live, setListParams, subscribe } = useLive(); - const [selection, setSelection] = useState({ sort: initialSort, window: initialWindow, chain: initialChain, filter: initialFilter }); - const { sort, window: window_, chain, filter } = selection; + const [selection, setSelection] = useState({ sort: initialSort, window: initialWindow, chain: initialChain, filter: initialFilter, mcap: initialMcap }); + const { sort, window: window_, chain, filter, mcap } = selection; const [view, setView] = useState(initialView); const [layout, setLayout] = useState(initialLayout); // `bare`: the list sits on the page (the full-screen home) instead of in its own box, aligned to the page column @@ -113,7 +114,7 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor useEffect(() => subscribe((snap) => { const current = selectionRef.current; const incoming = snap.launches; - if (!incoming || snap.sort !== current.sort || snap.window !== current.window || (snap.chain ?? null) !== current.chain || (snap.filter ?? null) !== current.filter || (snap.limit !== undefined && snap.limit !== limitRef.current)) return; + if (!incoming || !matchesListSelection({ ...current, limit: limitRef.current }, snap)) return; const t = Date.now(); const changes = new Map(); for (const row of incoming) { @@ -150,6 +151,7 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor if (next.window !== "all") p.set("window", next.window); if (next.chain) p.set("chain", next.chain); if (next.filter) p.set("filter", next.filter); + if (next.mcap === "all") p.set("mcap", "all"); if (v === "watchlist") p.set("view", "watchlist"); const href = p.size ? `/?${p}` : "/"; if (window.location.pathname === "/") window.history.replaceState(null, "", href); @@ -157,8 +159,9 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor return p; } - function pick(s: LaunchSort, w: VolumeWindow = window_, c: ChainKey | null = chain, f: LaunchFilter | null = filter, v: ListView = view) { - const next = { sort: s, window: w, chain: c, filter: f }; + /** Apply a selection, clear the held order and pagination, persist the URL, and request its first page. */ + function pick(s: LaunchSort, w: VolumeWindow = window_, c: ChainKey | null = chain, f: LaunchFilter | null = filter, v: ListView = view, m: McapMode = mcap) { + const next = { sort: s, window: w, chain: c, filter: f, mcap: m }; if (JSON.stringify(next) === JSON.stringify(selectionRef.current)) { if (v !== view) { setView(v); syncUrl(next, v); } return; @@ -184,13 +187,15 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor setUpdating(true); setLoadError(null); 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 + request.set("mcap", next.mcap); 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"); - const data = await res.json() as { launches: L[]; has_more: boolean }; + const data = await res.json() as { launches: L[]; has_more: boolean } & Parameters[1]; if (controller.signal.aborted || generation.current !== version) return; + if (!matchesListSelection({ ...next, limit: PAGE_SIZE }, data)) throw new Error("List selection changed"); previous.current = new Map(data.launches.map((row) => [launchKey(row), row])); pendingOrder.current = null; setRows(data.launches); @@ -223,6 +228,7 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor return () => { controller.abort(); clearTimeout(timer); }; }, [nq, chain, router]); + /** Append the next page only if its selection still matches, then expand the live refresh limit. */ async function loadMore() { if (loadingMore || updating) return; selectionRequest.current?.abort(); @@ -231,12 +237,14 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor setLoadError(null); try { const p = new URLSearchParams({ sort, window: window_, limit: String(PAGE_SIZE), offset: String(rows.length) }); + p.set("mcap", mcap); if (chain) p.set("chain", chain); if (filter) p.set("filter", filter); const res = await fetch(`/api/launch/list?${p}`, { cache: "no-store" }); if (!res.ok) throw new Error("List unavailable"); - const data = await res.json() as { launches: L[]; has_more: boolean }; + const data = await res.json() as { launches: L[]; has_more: boolean } & Parameters[1]; if (generation.current !== version) return; + if (!matchesListSelection({ ...selectionRef.current, limit: PAGE_SIZE }, data)) throw new Error("List selection changed"); const merged = mergeLaunches(rows, data.launches); for (const row of data.launches) previous.current.set(launchKey(row), row); pendingOrder.current = null; @@ -263,7 +271,8 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor const shown = candidates.filter((row) => (!chain || row.chain === chain) && matchesFilter(row, filter, now) && (!nq || matchesQuery(row, nq))); if (nq) shown.sort((a, b) => rankHit(a, nq) - rankHit(b, nq)); const showWindow = sort === "volume"; - const reset = () => { setQ(""); pick("live", "all", null, null); }; + /** Clear search and filters, returning to the default active market view with valuation filtering enabled. */ + const reset = () => { setQ(""); pick("live", "all", null, null, "market", "filtered"); }; const ranked = sort === "live" && !nq; // tiers, chips and the quiet divider apply to the live view only const firstQuiet = ranked ? shown.findIndex((row) => liveTier(row, now) === "quiet") : -1; // one divider, where the database's order enters the quiet tier @@ -276,7 +285,7 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor

Launches

{live.totals.launches}
-

{view === "watchlist" ? "The tokens you starred, and what changed since you last looked." : sort === "live" ? "Tokens with buyers first. Every launch stays in New." : "Every token. Open from the start."}

+

{view === "watchlist" ? "The tokens you starred, and what changed since you last looked." : nq ? "Search covers all launches." : sort === "mcap" && mcap === "filtered" ? "Extreme starting valuations with limited outside trading are hidden." : sort === "live" ? "Tokens with buyers first. Every launch stays in New." : "Every token. Open from the start."}

@@ -296,6 +305,13 @@ export default function LaunchList({ initial, initialHasMore = false, initialSor
+ {sort === "mcap" && view === "market" && !nq ?
+ +
How this filter works

{MCAP_FILTER_DESCRIPTION} Market cap is price × total supply (FDV).

+
: null}
{ const value = values[0]; if (!value) return; const c = value === "all" ? null : (value as ChainKey); const keep = !filter || FILTERS.some((f) => f.key === filter && filterOnChain(f, c)); pick(sort, window_, c, keep ? filter : null); }}> {CHAIN_FILTERS.map((c) => {c.key ? : null}{c.label})} diff --git a/app/src/components/launchpad/LaunchRow.tsx b/app/src/components/launchpad/LaunchRow.tsx index 007eafc0..6935a365 100644 --- a/app/src/components/launchpad/LaunchRow.tsx +++ b/app/src/components/launchpad/LaunchRow.tsx @@ -16,6 +16,7 @@ import type { LiveTier } from "@/lib/launchpad/ranking"; import { MorphAvatar, MorphName } from "./TokenMorph"; import { ChainCorner } from "./ChainLogo"; import { setPendingToken } from "@/lib/launchpad/token-transition"; +import { MCAP_FILTER_DESCRIPTION } from "@/lib/launchpad/mcap-policy"; /** Token, market cap, since launch, volume, buys / sells, holders, age; the star sits in the right gutter (md:pr-13). */ const columns = "md:grid-cols-[minmax(0,1fr)_6.25rem_4.5rem_5rem_6rem_3.75rem_2.5rem]"; @@ -88,6 +89,7 @@ export default function LaunchRow({ l, rank, window = "all", hl = null, now, pop {/* phones show the badge's mark only, so the name keeps its room */} {l.quote_key === "other" ? : null} + {l.mcap?.classification === "limited-outside-trading" ? Limited outside trading : null} {hl?.kind === "new" ? New : null}
{/* ticker, chain and fee: one line that truncates as a whole */} diff --git a/app/src/components/launchpad/LiveProvider.tsx b/app/src/components/launchpad/LiveProvider.tsx index 2da444c0..f03d95a4 100644 --- a/app/src/components/launchpad/LiveProvider.tsx +++ b/app/src/components/launchpad/LiveProvider.tsx @@ -5,6 +5,8 @@ import type { FeedItem, LaunchRow, LaunchSort, LaunchTotals, TrendingSnap, Volum import type { ChainKey } from "@/lib/chainPublic"; import type { LaunchFilter } from "@/lib/launchpad/search"; import type { PostRow } from "@/lib/launchpad/postsServer"; +import type { McapMode } from "@/lib/launchpad/mcap-policy"; +import { matchesListSelection } from "@/lib/launchpad/list-state"; /** * One poller for the whole page. Everything live (toasts, tape, hero totals, @@ -22,12 +24,14 @@ export type Live = { window: VolumeWindow; chain: ChainKey | null; filter?: LaunchFilter | null; + mcap?: McapMode; + mcap_policy?: number; limit?: number; has_more?: boolean | null; posts?: PostRow[]; trending?: TrendingSnap; }; -type ListParams = { sort: LaunchSort; window: VolumeWindow; chain: ChainKey | null; filter: LaunchFilter | null; limit: number }; +type ListParams = { sort: LaunchSort; window: VolumeWindow; chain: ChainKey | null; filter: LaunchFilter | null; mcap: McapMode; limit: number }; type Listener = (live: Live) => void; type Ctx = { live: Live; @@ -39,28 +43,31 @@ type Ctx = { const LiveCtx = createContext(null); const POLL_MS = 5_000; +/** Share visibility-aware polling across the page and discard launch lists for selections that changed in flight. */ export default function LiveProvider({ initial, children }: { initial: Omit & Partial>; children: React.ReactNode }) { const [live, setLive] = useState({ launches: null, sort: null, window: "all", chain: null, ...initial }); - const params = useRef(initial.sort ? { sort: initial.sort, window: initial.window ?? "all", chain: initial.chain ?? null, filter: initial.filter ?? null, limit: initial.limit ?? 40 } : null); + const params = useRef(initial.sort ? { sort: initial.sort, window: initial.window ?? "all", chain: initial.chain ?? null, filter: initial.filter ?? null, mcap: initial.mcap ?? "filtered", limit: initial.limit ?? 40 } : null); const inflight = useRef(false); const listeners = useRef(new Set()); const last = useRef(live); + /** Poll once when visible, merge available totals, and publish only the currently requested list. */ const tick = useCallback(async () => { if (inflight.current || document.visibilityState !== "visible") return; inflight.current = true; try { const p = params.current; - const q = p ? `?sort=${p.sort}&window=${p.window}&limit=${p.limit}${p.chain ? `&chain=${p.chain}` : ""}${p.filter ? `&filter=${p.filter}` : ""}` : ""; + const q = p ? `?sort=${p.sort}&window=${p.window}&limit=${p.limit}&mcap=${p.mcap}${p.chain ? `&chain=${p.chain}` : ""}${p.filter ? `&filter=${p.filter}` : ""}` : ""; const res = await fetch(`/api/launch/live${q}`, { cache: "no-store" }); if (!res.ok) return; const data = (await res.json()) as Live; // a list for a sort we no longer want is dropped, not shown - if (data.launches && p && (data.sort !== p.sort || data.window !== p.window || (data.chain ?? null) !== p.chain || (data.filter ?? null) !== p.filter)) data.launches = null; + const current = params.current; + if (data.launches && (!current || !matchesListSelection(current, data))) data.launches = null; // totals merge field-by-field: during a rolling deploy a poll can land on a machine that predates a newly added // total, and a missing field must fall back to the last value the client already had, never to undefined in render // by_chain is merged one level deeper for the same reason: an older machine answers without a newly added chain - const merged: Live = { ...data, totals: { ...last.current.totals, ...data.totals, by_chain: { ...last.current.totals.by_chain, ...data.totals?.by_chain } }, launches: data.launches ?? (p ? last.current.launches : null) }; + const merged: Live = { ...data, totals: { ...last.current.totals, ...data.totals, by_chain: { ...last.current.totals.by_chain, ...data.totals?.by_chain } }, launches: data.launches ?? null }; last.current = merged; setLive(merged); listeners.current.forEach((fn) => fn(merged)); diff --git a/app/src/lib/launchpad/list-state.test.ts b/app/src/lib/launchpad/list-state.test.ts index a3d76483..0d70ecf8 100644 --- a/app/src/lib/launchpad/list-state.test.ts +++ b/app/src/lib/launchpad/list-state.test.ts @@ -1,6 +1,19 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { launchKey, mergeLaunches, refreshInPlace } from "./list-state.ts"; +import { launchKey, matchesListSelection, mergeLaunches, refreshInPlace } from "./list-state.ts"; +import { MCAP_POLICY_VERSION } from "./mcap-policy.ts"; + +test("market-cap requests reject previous modes, old servers and previous pages", () => { + const wanted = { sort: "mcap", window: "all", chain: null, filter: null, mcap: "filtered" as const, limit: 40 }; + const response = { ...wanted, mcap_policy: MCAP_POLICY_VERSION }; + assert.equal(matchesListSelection(wanted, response), true); + assert.equal(matchesListSelection(wanted, { ...response, mcap: "all" }), false); + assert.equal(matchesListSelection(wanted, { ...response, mcap_policy: undefined }), false); + assert.equal(matchesListSelection(wanted, { ...response, mcap: undefined }), false); + assert.equal(matchesListSelection(wanted, { ...response, limit: 80 }), false); + assert.equal(matchesListSelection(wanted, { ...response, chain: "arc" }), false); + assert.equal(matchesListSelection({ ...wanted, sort: "new" }, { ...response, sort: "new", mcap_policy: undefined, mcap: undefined }), true); +}); test("launch identity includes chain and normalizes address case", () => { assert.equal(launchKey({ chain: "base", token: "0xAbC" }), "base:0xabc"); diff --git a/app/src/lib/launchpad/list-state.ts b/app/src/lib/launchpad/list-state.ts index 042dec1a..1ebd76fd 100644 --- a/app/src/lib/launchpad/list-state.ts +++ b/app/src/lib/launchpad/list-state.ts @@ -1,4 +1,15 @@ /** Shared list identity and request guards. Chain is part of every token identity. */ +import { MCAP_POLICY_VERSION, type McapMode } from "./mcap-policy"; + +type ListSelection = { sort: string | null; window: string; chain?: string | null; filter?: string | null; limit?: number; mcap?: McapMode }; +/** A rolling deploy or a previous selection must never silently supply an unfiltered market-cap page. */ +export function matchesListSelection(wanted: ListSelection, received: ListSelection & { mcap_policy?: number }): boolean { + return wanted.sort === received.sort && wanted.window === received.window && + (wanted.chain ?? null) === (received.chain ?? null) && (wanted.filter ?? null) === (received.filter ?? null) && + (received.limit === undefined || wanted.limit === received.limit) && + (wanted.sort !== "mcap" || ((wanted.mcap ?? "filtered") === received.mcap && received.mcap_policy === MCAP_POLICY_VERSION)); +} + export function launchKey(row: { chain: string; token: string }): string { return `${row.chain}:${row.token.toLowerCase()}`; } diff --git a/app/src/lib/launchpad/market-cap.test.ts b/app/src/lib/launchpad/market-cap.test.ts index b378696c..f07a47b6 100644 --- a/app/src/lib/launchpad/market-cap.test.ts +++ b/app/src/lib/launchpad/market-cap.test.ts @@ -67,3 +67,12 @@ test("capDisplay: dollars lead with the quote figure as detail; without a price assert.deepEqual(capDisplay(0.5, 0, nvda), { main: "0.500 NVDA", detail: NO_USD_PRICE, compact: "0.500 NVDA · no USD price", usd: null }); assert.deepEqual(capDisplay(0.5, 1, usdg), { main: "$0.5", detail: "0.5 USDG", compact: "$0.5", usd: 0.5 }); }); + +test("extreme quote valuations and invalid API values cannot produce endless digits or crash", () => { + assert.equal(capQuoteLabel(1e35, usdg), "1.0e+35 USDG"); + assert.equal(capQuoteLabel(1e35, nvda), "1.0e+35 NVDA"); + for (const value of [NaN, Infinity, -1, null]) { + assert.equal(capDisplay(value as number, 1, usdg).main, "—"); + } + assert.equal(capDisplay(Number.MAX_VALUE, 2, eth).main, "—"); +}); diff --git a/app/src/lib/launchpad/market-cap.ts b/app/src/lib/launchpad/market-cap.ts index 433f9e0d..cabdffed 100644 --- a/app/src/lib/launchpad/market-cap.ts +++ b/app/src/lib/launchpad/market-cap.ts @@ -44,6 +44,8 @@ export function capChipLabel(v: number, entry: CapEntry, quote: Pick): string { + if (typeof fdvQuote !== "number" || !Number.isFinite(fdvQuote) || fdvQuote < 0) return "—"; + if (fdvQuote >= 1e15) return `${fdvQuote.toExponential(1)} ${quote.symbol}`; if (quote.decimals <= 6) return `${fdvQuote.toLocaleString("en-US", { maximumFractionDigits: Math.abs(fdvQuote) < 100 ? 2 : 0 })} ${quote.symbol}`; // a 0.5 USDG cap is "0.5 USDG", not "1 USDG" if (quote.key === "stock") return `${fdvQuote.toFixed(3)} ${quote.symbol}`; return `${fmtQuoteUnits(fdvQuote, quote.decimals)} ${quote.symbol}`; @@ -59,8 +61,10 @@ export const NO_USD_PRICE = "no USD price"; */ export function capDisplay(fdvQuote: number, quoteUsd: number | null, quote: Pick): { main: string; detail: string; compact: string; usd: number | null } { const quoteText = capQuoteLabel(fdvQuote, quote); + if (quoteText === "—") return { main: "—", detail: "valuation unavailable", compact: "—", usd: null }; if (quoteUsd !== null && Number.isFinite(quoteUsd) && quoteUsd > 0) { const usd = fdvQuote * quoteUsd; + if (!Number.isFinite(usd)) return { main: "—", detail: quoteText, compact: "—", usd: null }; const main = marketUsd(usd); return { main, detail: quoteText, compact: main, usd }; } diff --git a/app/src/lib/launchpad/market-format.test.ts b/app/src/lib/launchpad/market-format.test.ts index 5c96dd92..2634f588 100644 --- a/app/src/lib/launchpad/market-format.test.ts +++ b/app/src/lib/launchpad/market-format.test.ts @@ -10,6 +10,12 @@ test("ledger money fits its column without rounding dust to zero", () => { assert.equal(marketUsd(Number.NaN), "—"); }); +test("extreme finite dollar valuations stay readable without changing their value", () => { + assert.equal(marketUsd(1e15), "$1.0e+15"); + assert.equal(marketUsd(9.994968468380422e35), "$1.0e+36"); + assert.ok(marketUsd(Number.MAX_VALUE).length < 14); +}); + test("rounded zero changes are neutral; extreme values stay bounded", () => { assert.deepEqual(marketChange(0.00000003), { label: "0.0%", direction: "flat" }); assert.deepEqual(marketChange(-0.00000003), { label: "0.0%", direction: "flat" }); diff --git a/app/src/lib/launchpad/market-format.ts b/app/src/lib/launchpad/market-format.ts index ee0a1ecd..f8620de9 100644 --- a/app/src/lib/launchpad/market-format.ts +++ b/app/src/lib/launchpad/market-format.ts @@ -3,6 +3,7 @@ export function marketUsd(value: number): string { if (!Number.isFinite(value)) return "—"; if (value === 0) return "$0"; if (value > 0 && value < 0.01) return "<$0.01"; + if (Math.abs(value) >= 1e15) return `$${value.toExponential(1)}`; return new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", notation: Math.abs(value) >= 1_000 ? "compact" : "standard", maximumFractionDigits: Math.abs(value) >= 1_000 ? 1 : 2, minimumFractionDigits: 0 }).format(value); } diff --git a/app/src/lib/launchpad/mcap-policy.test.ts b/app/src/lib/launchpad/mcap-policy.test.ts new file mode 100644 index 00000000..73772b40 --- /dev/null +++ b/app/src/lib/launchpad/mcap-policy.test.ts @@ -0,0 +1,34 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { classifyMcap, MCAP_HIGH_USD, MCAP_SUPPORT_RATIO, parseMcapMode, type McapFacts } from "./mcap-policy.ts"; + +/** Start with an unsupported high valuation and override only the facts relevant to each policy boundary. */ +const facts = (overrides: Partial = {}): McapFacts => ({ startFdvQuote: 20_000_000, fdvQuote: 20_000_000, quoteUsd: 1, outsideVolumeQuote: 0, outsideTraders: 0, ...overrides }); + +test("small launches need no volume, trades or holders; DOTTIE remains included on a quiet day", () => { + for (const fdv of [1, 5_000, 25_700, 100_000, MCAP_HIGH_USD - 1]) { + assert.equal(classifyMcap(facts({ startFdvQuote: fdv, fdvQuote: fdv, outsideVolumeQuote: null, outsideTraders: null })), "included"); + } +}); +test("both launch and current valuations must be high", () => { + assert.equal(classifyMcap(facts({ startFdvQuote: 25_000, fdvQuote: 50_000_000 })), "included"); + assert.equal(classifyMcap(facts({ startFdvQuote: 50_000_000, fdvQuote: 25_000 })), "included"); + assert.equal(classifyMcap(facts({ startFdvQuote: 1e35, fdvQuote: 1e35, outsideVolumeQuote: 0.02 })), "limited-outside-trading"); +}); +test("extreme valuations need enough lifetime volume and distinct meaningful outside wallets", () => { + const start = MCAP_HIGH_USD; + const supported = facts({ startFdvQuote: start, fdvQuote: start, outsideVolumeQuote: start * MCAP_SUPPORT_RATIO, outsideTraders: 3 }); + assert.equal(classifyMcap(supported), "included"); + assert.equal(classifyMcap({ ...supported, outsideVolumeQuote: supported.outsideVolumeQuote! - 0.01 }), "limited-outside-trading"); + assert.equal(classifyMcap({ ...supported, outsideTraders: 2 }), "limited-outside-trading"); + assert.equal(classifyMcap(facts({ startFdvQuote: start / 2500, fdvQuote: start / 2500, quoteUsd: 2500, outsideVolumeQuote: start / 2500 * MCAP_SUPPORT_RATIO, outsideTraders: 3 })), "included"); +}); +test("unknown or invalid prices cannot classify a launch as an outlier", () => { + for (const quoteUsd of [null, 0, -1, NaN, Infinity]) assert.equal(classifyMcap(facts({ quoteUsd })), "unpriced"); + for (const fdvQuote of [0, -1, NaN, Infinity]) assert.equal(classifyMcap(facts({ fdvQuote })), "unpriced"); + assert.equal(classifyMcap(facts({ fdvQuote: Number.MAX_VALUE, quoteUsd: 2 })), "unpriced"); +}); +test("market-cap mode is allow-listed and defaults to filtered", () => { + assert.equal(parseMcapMode("all"), "all"); + for (const raw of [undefined, null, "filtered", "ALL", "true", ["all"]]) assert.equal(parseMcapMode(raw), "filtered"); +}); diff --git a/app/src/lib/launchpad/mcap-policy.ts b/app/src/lib/launchpad/mcap-policy.ts new file mode 100644 index 00000000..15441556 --- /dev/null +++ b/app/src/lib/launchpad/mcap-policy.ts @@ -0,0 +1,38 @@ +/** Market-cap browsing policy. Small launches never need volume or holders to appear. */ +export const MCAP_HIGH_USD = 10_000_000; +export const MCAP_SUPPORT_RATIO = 0.001; +export const MCAP_MIN_TRADERS = 3; +export const MCAP_MIN_WALLET_USD = 1; +export const MCAP_POLICY_VERSION = 1; + +export type McapMode = "filtered" | "all"; +/** Accept only the explicit all-valuations opt-out; missing or unknown URL values keep filtering enabled. */ +export function parseMcapMode(raw: unknown): McapMode { + return raw === "all" ? "all" : "filtered"; +} + +export type McapFacts = { + startFdvQuote: number; + fdvQuote: number; + quoteUsd: number | null; + /** Only measured for high starting/current valuations; null otherwise. */ + outsideVolumeQuote: number | null; + outsideTraders: number | null; +}; +export type McapClassification = "included" | "limited-outside-trading" | "unpriced"; + +/** + * Classify quote-denominated FDVs using the current quote/USD price and lifetime outside-trading facts. + * Invalid valuations stay unpriced; small starting or current valuations never need trading support. + */ +export function classifyMcap(facts: McapFacts): McapClassification { + const { startFdvQuote, fdvQuote, quoteUsd, outsideVolumeQuote, outsideTraders } = facts; + if (quoteUsd === null || !Number.isFinite(quoteUsd) || quoteUsd <= 0 || + !Number.isFinite(startFdvQuote) || startFdvQuote <= 0 || !Number.isFinite(fdvQuote) || fdvQuote <= 0 || + !Number.isFinite(startFdvQuote * quoteUsd) || !Number.isFinite(fdvQuote * quoteUsd)) return "unpriced"; + if (startFdvQuote * quoteUsd < MCAP_HIGH_USD || fdvQuote * quoteUsd < MCAP_HIGH_USD) return "included"; + return outsideVolumeQuote !== null && Number.isFinite(outsideVolumeQuote) && outsideVolumeQuote >= startFdvQuote * MCAP_SUPPORT_RATIO && + outsideTraders !== null && outsideTraders >= MCAP_MIN_TRADERS ? "included" : "limited-outside-trading"; +} + +export const MCAP_FILTER_DESCRIPTION = "Starting and current valuations of at least $10M need lifetime outside trading of at least 0.1% of the starting valuation, from at least 3 wallets trading $1 each. Creator trades and the launch block plus the next 3 blocks are excluded. Both valuations use the current quote/USD price. Small launches remain visible with zero trades."; diff --git a/app/src/lib/launchpad/mcap-query.ts b/app/src/lib/launchpad/mcap-query.ts new file mode 100644 index 00000000..f2bc32c3 --- /dev/null +++ b/app/src/lib/launchpad/mcap-query.ts @@ -0,0 +1,66 @@ +import type { Db } from "../db"; +import type postgres from "postgres"; +import { MCAP_HIGH_USD, MCAP_MIN_TRADERS, MCAP_MIN_WALLET_USD, MCAP_SUPPORT_RATIO, type McapMode } from "./mcap-policy"; +import { SNIPER_BLOCKS } from "./holders"; + +type Fragment = postgres.PendingQuery; +export type McapColumns = { + _mcap_fdv_quote: number | null; + _mcap_start_quote: number | null; + _mcap_outside_quote: number | null; + _mcap_traders: number | null; +}; + +/** + * Assess valuations and outside trading before paging, then hydrate the page with rolling stats and metadata. + * @throws If the hydration SELECT loses its expected prefix, rather than silently omitting market-cap facts. + */ +export function mcapPageQuery(db: Db, opts: { where: Fragment; quoteDecimals: Fragment; quoteUsd: Fragment; mode: McapMode; limit: number; offset: number; select: string }) { + const { where, quoteDecimals: qd, quoteUsd: usd, mode, limit, offset } = opts; + if (!opts.select.startsWith("SELECT l.*,")) throw new Error("mcapPageQuery: unexpected SELECT prefix"); + const select = opts.select.replace("SELECT l.*,", "SELECT l.*, page._mcap_fdv_quote, page._mcap_start_quote, page._mcap_outside_quote, page._mcap_traders,"); + return db` + WITH valued AS MATERIALIZED ( + SELECT l.chain_id, l.token, l.launcher, l.block_time, l.block_number, + ${qd} AS quote_decimals, ${usd} AS quote_usd, + CASE WHEN l.supply > 0 AND l.start_tick BETWEEN -887272 AND 887272 THEN + (l.supply::double precision / 1e18) / (power(1.0001, l.start_tick::double precision) * power(10, ${qd} - 18)) END AS _mcap_start_quote, + CASE WHEN l.supply > 0 THEN + CASE WHEN l.sqrt_price_x96 > 0 THEN + (l.supply::double precision / 1e18) / (power(l.sqrt_price_x96::double precision / 79228162514264337593543950336.0, 2) * power(10, ${qd} - 18)) + WHEN COALESCE(l.tick, l.start_tick) BETWEEN -887272 AND 887272 THEN + (l.supply::double precision / 1e18) / (power(1.0001, COALESCE(l.tick, l.start_tick)::double precision) * power(10, ${qd} - 18)) END + END AS _mcap_fdv_quote + FROM bb_launches l ${where} + ), assessed AS ( + SELECT v.*, v._mcap_fdv_quote * v.quote_usd AS fdv_usd, + CASE WHEN v._mcap_start_quote * v.quote_usd >= ${MCAP_HIGH_USD} AND v._mcap_fdv_quote * v.quote_usd >= ${MCAP_HIGH_USD} + THEN COALESCE(outside.volume, 0) / power(10, v.quote_decimals) END AS _mcap_outside_quote, + CASE WHEN v._mcap_start_quote * v.quote_usd >= ${MCAP_HIGH_USD} AND v._mcap_fdv_quote * v.quote_usd >= ${MCAP_HIGH_USD} + THEN COALESCE(outside.traders, 0)::int END AS _mcap_traders, + COALESCE(v._mcap_start_quote * v.quote_usd >= ${MCAP_HIGH_USD} + AND v._mcap_fdv_quote * v.quote_usd >= ${MCAP_HIGH_USD} + AND NOT (COALESCE(outside.volume, 0) / power(10, v.quote_decimals) >= v._mcap_start_quote * ${MCAP_SUPPORT_RATIO} + AND COALESCE(outside.traders, 0) >= ${MCAP_MIN_TRADERS}), false) AS outlier + FROM valued v + LEFT JOIN LATERAL ( + SELECT sum(wallet.volume) AS volume, + count(*) FILTER (WHERE wallet.volume / power(10, v.quote_decimals) * v.quote_usd >= ${MCAP_MIN_WALLET_USD}) AS traders + FROM ( + SELECT sum(abs(s.amount0)) AS volume FROM bb_launch_swaps s + WHERE s.chain_id = v.chain_id AND s.token = v.token AND s.trader <> v.launcher + AND s.block_number > v.block_number + ${SNIPER_BLOCKS} + AND v._mcap_start_quote * v.quote_usd >= ${MCAP_HIGH_USD} + AND v._mcap_fdv_quote * v.quote_usd >= ${MCAP_HIGH_USD} + GROUP BY s.trader + ) wallet + ) outside ON true + ), mcap_page AS ( + SELECT * FROM assessed WHERE ${mode === "all"} OR NOT outlier + ORDER BY fdv_usd DESC NULLS LAST, block_time DESC, chain_id DESC, block_number DESC, token ASC + LIMIT ${limit + 1} OFFSET ${offset} + ) + ${db.unsafe(select)} + JOIN mcap_page page ON page.chain_id = l.chain_id AND page.token = l.token + ORDER BY page.fdv_usd DESC NULLS LAST, page.block_time DESC, page.chain_id DESC, page.block_number DESC, page.token ASC`; +} diff --git a/app/src/lib/launchpad/museworld.test.ts b/app/src/lib/launchpad/museworld.test.ts index 8eefa3c3..48270f44 100644 --- a/app/src/lib/launchpad/museworld.test.ts +++ b/app/src/lib/launchpad/museworld.test.ts @@ -61,7 +61,8 @@ test("server: priced from its own GITLAWB pool, guarded by the average, fails so assert.match(q, /row\.quote !== GITLAWB_ADDRESS\.toLowerCase\(\)\) return null;/, "only the MUSEWORLD/GITLAWB pool may price it"); assert.match(q, /reconcileGitlawbUsd\(spot > 0 \? spot : null, twap\)/); assert.match(q, /perMuseworld \* gitlawbUsd/); - assert.match(q, /museworldUsdNow = null; \/\/ fail soft/); + assert.match(q, /let museworld: number \| null = null;/, "each request starts fail-soft instead of borrowing another request's price"); + assert.match(q, /museworldUsdNow = museworld;/); assert.match(q, /WHEN \$\{quoteArms\("museworld"\)\} THEN \$\{museworldFactor\}::double precision/); }); diff --git a/app/src/lib/launchpad/queries.ts b/app/src/lib/launchpad/queries.ts index 8e85956b..8d6d4c5e 100644 --- a/app/src/lib/launchpad/queries.ts +++ b/app/src/lib/launchpad/queries.ts @@ -3,7 +3,7 @@ import { cache } from "react"; import { maybeDb } from "@/lib/db"; import { CHAIN_KEYS, DEFAULT_CHAIN, chainIdOf, chainKeyOf, type ChainKey } from "@/lib/chainPublic"; import { quoteInfo as staticQuoteInfo, quoteUsdOf, type Quote, NATIVE_QUOTES, fixedUsdQuotes, quotesWithKey } from "./config"; -import { ensureRegistry, stockByAddress, stockList, stockUsdInUse } from "./stocksServer"; +import { ensureRegistry, stockList, stockUsdInUse, type Stock } from "./stocksServer"; import { unlistedQuote, type QuoteTokenMeta } from "./unlisted-quote"; import { memo } from "./memo"; import { gitlawbUsd } from "./gitlawbServer"; @@ -17,6 +17,8 @@ import { imagePublicBase } from "./imageStore"; import { collectNonDust } from "./feed-dust"; import { PIPS_LEADER, PIPS_RUNNER, pipFromSwapRow, tapeOrNone, type Pip } from "./trending-live"; import { launchKey } from "./list-state"; +import { classifyMcap, parseMcapMode, type McapFacts, type McapClassification, type McapMode } from "./mcap-policy"; +import { mcapPageQuery, type McapColumns } from "./mcap-query"; import { fdvQuote, quotePerToken, tickToTokensPerQuote, units } from "./math"; import type { RawCandle } from "./candles"; import { normalizeQuery, isAddressQuery, escapeLike, compareSearchHit, compareSearchHitByHolders, type LaunchFilter } from "./search"; @@ -86,6 +88,8 @@ export type LaunchRow = { volume_usd: number | null; volume_1h_usd: number | null; volume_24h_usd: number | null; + /** Present on the market-cap list; the facts behind the optional browsing filter. */ + mcap?: McapFacts & { classification: McapClassification }; }; type Raw = Omit & { @@ -109,14 +113,16 @@ let stockUsdNow = new Map(); /** Unlisted quotes' on-chain symbol / decimals (bb_quote_tokens, keyed `${chain_id}:${address}`), and each chain's stock tickers they may not borrow. */ let quoteTokensNow = new Map(); let stockSymbolsNow = new Map(); +let stockQuotesNow = new Map(); /** MUSEWORLD USD for this request (filled by `withStocks` from its own GITLAWB pool; null = unknown → no USD, 0 weight in USD sorts). */ let museworldUsdNow: number | null = null; /** GITLAWB USD for this request (filled by `withStocks`; null = unknown → no USD, 0 weight in USD sorts). */ let gitlawbUsdNow: number | null = null; /** The quote prices in use, as one value: what `withStocks` last loaded (each piece is replaced, never edited). */ -type PriceState = { gitlawb: number | null; museworld: number | null; stocks: Map; quoteTokens: Map; stockSymbols: Map }; -const priceState = (): PriceState => ({ gitlawb: gitlawbUsdNow, museworld: museworldUsdNow, stocks: stockUsdNow, quoteTokens: quoteTokensNow, stockSymbols: stockSymbolsNow }); +type PriceState = { gitlawb: number | null; museworld: number | null; stocks: Map; quoteTokens: Map; stockSymbols: Map; stockQuotes: Map }; +/** Capture the last published quote snapshot for readers that do not supply their own request-local snapshot. */ +const priceState = (): PriceState => ({ gitlawb: gitlawbUsdNow, museworld: museworldUsdNow, stocks: stockUsdNow, quoteTokens: quoteTokensNow, stockSymbols: stockSymbolsNow, stockQuotes: stockQuotesNow }); /** * Server-side quote resolution: static ETH/USDG/GITLAWB/TWIG (GITLAWB gets the live price, TWIG the same one), else a @@ -128,7 +134,7 @@ function quoteInfo(chain: ChainKey, address: string, st: PriceState = priceState if (linked !== undefined) return { ...q, usd: linked }; if (q.key === "museworld") return { ...q, usd: st.museworld }; if (q.key !== "other") return q; - const stock = stockByAddress(chain, address); + const stock = st.stockQuotes.get(`${chain}:${address.toLowerCase()}`); if (stock) return { key: "stock", address: stock.address as Quote["address"], symbol: stock.symbol, decimals: stock.decimals, usd: st.stocks.get(stock.address) ?? null, name: stock.name, logo: stock.logo }; return unlistedQuote(address, st.quoteTokens.get(`${chainIdOf(chain)}:${address.toLowerCase()}`) ?? null, st.stockSymbols.get(chain) ?? []); } @@ -174,27 +180,38 @@ async function loadQuoteTokens(): Promise> { return new Map(rows.map((r) => [`${r.chain_id}:${r.address}`, { symbol: r.symbol, name: r.name, decimals: r.decimals }])); } -/** Warm the stock registry + prices for the stocks in use (and the GITLAWB price), so `shape()` can stay synchronous. */ -async function withStocks(): Promise { - gitlawbUsdNow = await gitlawbUsd(); // never throws; served from cache once warm (stale-while-revalidate) +/** Load quote prices and metadata into a request-local snapshot; unavailable feeds retain null USD prices. */ +async function withStocks(): Promise { + const gitlawb = await gitlawbUsd(); // never throws; served from cache once warm (stale-while-revalidate) + let stocks = new Map(); + let museworld: number | null = null; + let quoteTokens = new Map(); try { await ensureRegistry(); // robinhood registry (fail-soft); the Base list is static - stockUsdNow = await stockUsdInUse(); + stocks = new Map(await stockUsdInUse()); } catch { /* fail soft: stocks show without USD */ } try { - museworldUsdNow = await memo("museworld-usd", 15_000, () => readMuseworldUsd(gitlawbUsdNow)); + museworld = await memo(`museworld-usd:${gitlawb}`, 15_000, () => readMuseworldUsd(gitlawb)); } catch (err) { - museworldUsdNow = null; // fail soft: MUSEWORLD pools show without USD console.warn("[museworld] price read failed:", err instanceof Error ? err.message : err); } - stockSymbolsNow = new Map(CHAIN_KEYS.map((k) => [k, stockList(k).map((s) => s.symbol)])); + const stockSymbols = new Map(CHAIN_KEYS.map((k) => [k, stockList(k).map((s) => s.symbol)])); + const stockQuotes = new Map(CHAIN_KEYS.flatMap((k) => stockList(k).map((s) => [`${k}:${s.address.toLowerCase()}`, s] as const))); try { - quoteTokensNow = await memo("quote-tokens", 30_000, loadQuoteTokens); + quoteTokens = new Map(await memo("quote-tokens", 30_000, loadQuoteTokens)); } catch { /* fail soft (table not migrated yet, db blip): unlisted quotes show by address and do not trade */ } + // Publish atomically for older readers; list requests retain their own snapshot across awaits. + gitlawbUsdNow = gitlawb; + museworldUsdNow = museworld; + stockUsdNow = stocks; + quoteTokensNow = quoteTokens; + stockSymbolsNow = stockSymbols; + stockQuotesNow = stockQuotes; + return { gitlawb, museworld, stocks, quoteTokens, stockSymbols, stockQuotes }; } /** A quote's USD price (ETH-quoted through the ETH price; null when unknown). */ @@ -203,18 +220,21 @@ function quoteUsd(q: Quote, ethUsd: number | null): number | null { } /** A launch row as the app uses it: indexer-only bigint columns dropped (JSON-safe), price, FDV and USD figures worked out. */ -function shape(raw: Raw & { last_swap_block?: bigint; last_swap_log?: number; log_index?: number; holders_synced_block?: bigint | null }, ethUsd: number | null): LaunchRow { +function shape(raw: Raw & Partial & { last_swap_block?: bigint; last_swap_log?: number; log_index?: number; holders_synced_block?: bigint | null }, ethUsd: number | null, prices: PriceState = priceState()): LaunchRow { // drop indexer-only bigint columns so the row is JSON-safe // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { last_swap_block, last_swap_log, log_index, holders_synced_block, ...r } = raw; + const { last_swap_block, last_swap_log, log_index, holders_synced_block, _mcap_fdv_quote, _mcap_start_quote, _mcap_outside_quote, _mcap_traders, ...r } = raw; const chain = chainKeyOf(r.chain_id) ?? DEFAULT_CHAIN; - const q = quoteInfo(chain, r.quote); + const q = quoteInfo(chain, r.quote, prices); const supply = BigInt(r.supply); const tick = r.tick ?? r.start_tick; - const price = r.sqrt_price_x96 ? quotePerToken(BigInt(r.sqrt_price_x96), q.decimals) : 1 / tickToTokensPerQuote(tick, q.decimals); - const fdv = r.sqrt_price_x96 ? fdvQuote(BigInt(r.sqrt_price_x96), supply, q.decimals) : price * (Number(supply) / 1e18); - const launchFdv = (1 / tickToTokensPerQuote(r.start_tick, q.decimals)) * (Number(supply) / 1e18); - const qu = quoteUsd(q, ethUsd); + const calculatedPrice = r.sqrt_price_x96 ? quotePerToken(BigInt(r.sqrt_price_x96), q.decimals) : 1 / tickToTokensPerQuote(tick, q.decimals); + const fdv = _mcap_fdv_quote !== undefined ? (_mcap_fdv_quote ?? Number.NaN) : r.sqrt_price_x96 ? fdvQuote(BigInt(r.sqrt_price_x96), supply, q.decimals) : calculatedPrice * (Number(supply) / 1e18); + const price = _mcap_fdv_quote !== undefined ? fdv / (Number(supply) / 1e18) : calculatedPrice; + const launchFdv = _mcap_start_quote !== undefined ? (_mcap_start_quote ?? Number.NaN) : (1 / tickToTokensPerQuote(r.start_tick, q.decimals)) * (Number(supply) / 1e18); + const quRaw = quoteUsd(q, ethUsd); + const qu = quRaw !== null && Number.isFinite(quRaw) && quRaw > 0 ? quRaw : null; + const mcapFacts: McapFacts = { startFdvQuote: launchFdv, fdvQuote: fdv, quoteUsd: qu, outsideVolumeQuote: _mcap_outside_quote ?? null, outsideTraders: _mcap_traders ?? null }; const usd = (raw: string) => (qu === null ? null : units(raw, q.decimals) * qu); return { ...r, @@ -243,11 +263,12 @@ function shape(raw: Raw & { last_swap_block?: bigint; last_swap_log?: number; lo image_url: canonicalImageUrl(r.image_url, imagePublicBase()), banner_url: canonicalImageUrl(r.banner_url, imagePublicBase()), quote_usd: qu, - price_usd: qu === null ? null : price * qu, - fdv_usd: qu === null ? null : fdv * qu, + price_usd: qu === null || !Number.isFinite(price * qu) ? null : price * qu, + fdv_usd: qu === null || !Number.isFinite(fdv * qu) ? null : fdv * qu, volume_usd: usd(r.volume_quote), volume_1h_usd: usd(String(r.volume_1h ?? "0")), volume_24h_usd: usd(String(r.volume_24h ?? "0")), + ...(_mcap_fdv_quote !== undefined ? { mcap: { ...mcapFacts, classification: classifyMcap(mcapFacts) } } : {}), }; } @@ -285,7 +306,7 @@ 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 }; +export type ListOpts = { sort?: LaunchSort; window?: VolumeWindow; chain?: ChainKey | null; filter?: LaunchFilter | null; mcap?: McapMode; limit?: number; offset?: number; launcher?: string; ethUsd?: number | null }; const NATIVE_ADDR = "0x0000000000000000000000000000000000000000"; const DEAD_ADDR = "0x000000000000000000000000000000000000dead"; @@ -306,15 +327,15 @@ export type ListPage = { items: LaunchRow[]; hasMore: boolean }; export async function listLaunchesPage(opts: ListOpts = {}): Promise { const db = maybeDb(); if (!db) return { items: [], hasMore: false }; - await withStocks(); + const prices = await withStocks(); const limit = Math.min(200, Math.max(1, opts.limit ?? 40)); const offset = Math.max(0, opts.offset ?? 0); const sort = opts.sort ?? "new"; const win = opts.window ?? "all"; const ethUsd = opts.ethUsd ?? null; - const ethFactor = ethUsd ?? 1; // no ETH price → rank ETH pools in ETH units (still monotonic within the chain) + const ethFactor = ethUsd !== null && Number.isFinite(ethUsd) && ethUsd > 0 ? ethUsd : null; // stock quotes: per-address USD factors known this request (empty → treated like unknown = 0 weight in USD sorts) - const stockEntries = [...stockUsdNow.entries()].filter(([, v]) => v !== null && v > 0) as [string, number][]; + const stockEntries = [...prices.stocks.entries()].filter(([, v]) => v !== null && Number.isFinite(v) && v > 0) as [string, number][]; const conds = [] as ReturnType[]; if (opts.chain) conds.push(db`l.chain_id = ${chainIdOf(opts.chain)}`); if (opts.launcher) conds.push(db`l.launcher = ${opts.launcher.toLowerCase()}`); @@ -336,26 +357,35 @@ export async function listLaunchesPage(opts: ListOpts = {}): Promise { const where = conds.length ? db`WHERE ${conds.reduce((a, c) => db`${a} AND ${c}`)}` : db``; // per-row USD factor and quote decimals (the stables, USDG and USDC, are the only fixed-price and non-18-dec quotes we list) // a registry stock is matched per (chain, address) like every other arm: the address is only a stock on the chain whose registry lists it - const stockChains = (a: string) => CHAIN_KEYS.filter((k) => stockByAddress(k, a) !== null); + /** Find the chains that list this stock address in this request's registry snapshot. */ + const stockChains = (a: string) => CHAIN_KEYS.filter((k) => prices.stockQuotes.has(`${k}:${a.toLowerCase()}`)); const stockArms = stockEntries.flatMap(([a, v]) => stockChains(a).map((k) => db`WHEN (l.chain_id = ${chainIdOf(k)} AND l.quote = ${a}) THEN ${v}::double precision`)); const stockCase = stockArms.length ? stockArms.reduce((acc, c) => db`${acc} ${c}`) : db``; - const gitlawbFactor = gitlawbUsdNow !== null && gitlawbUsdNow > 0 ? gitlawbUsdNow : 0; // unknown → 0 weight, like an unknown stock - const museworldFactor = museworldUsdNow !== null && museworldUsdNow > 0 ? museworldUsdNow : 0; + const gitlawbFactor = prices.gitlawb !== null && Number.isFinite(prices.gitlawb) && prices.gitlawb > 0 ? prices.gitlawb : null; + const museworldFactor = prices.museworld !== null && Number.isFinite(prices.museworld) && prices.museworld > 0 ? prices.museworld : null; const stables = fixedUsdQuotes(); const stableCase = stables.length ? stables.map((s) => db`WHEN (l.chain_id = ${chainIdOf(s.chain)} AND l.quote = ${s.address}) THEN ${s.usd}::double precision`).reduce((acc, c) => db`${acc} ${c}`) : db``; // address(0) is ETH only where the chain's native asset is ETH; a native stable (Arc: USDC) is already in stableCase above const ethNativeArms = CHAIN_KEYS.filter((k) => NATIVE_QUOTES[k].key === "eth").map((k) => db`WHEN (l.chain_id = ${chainIdOf(k)} AND l.quote = ${NATIVE_ADDR}) THEN ${ethFactor}::double precision`); const ethNativeCase = ethNativeArms.length ? ethNativeArms.reduce((acc, c) => db`${acc} ${c}`) : db``; - const usdPerUnit = db`(CASE ${stableCase} ${stockCase} WHEN ${isGitlawbPriced()} THEN ${gitlawbFactor}::double precision WHEN ${quoteArms("museworld")} THEN ${museworldFactor}::double precision ${ethNativeCase} ELSE 0.0 END)`; - const stockDecArms = stockEntries.flatMap(([a]) => stockChains(a).flatMap((k) => { const d = stockByAddress(k, a)!.decimals; return d !== 18 ? [db`WHEN (l.chain_id = ${chainIdOf(k)} AND l.quote = ${a}) THEN ${d}`] : []; })); + const usdPerUnit = db`(CASE ${stableCase} ${stockCase} WHEN ${isGitlawbPriced()} THEN ${gitlawbFactor}::double precision WHEN ${quoteArms("museworld")} THEN ${museworldFactor}::double precision ${ethNativeCase} ELSE NULL::double precision END)`; + const stockDecArms = [...prices.stockQuotes.values()].filter((s) => s.decimals !== 18).map((s) => db`WHEN (l.chain_id = ${chainIdOf(s.chain)} AND l.quote = ${s.address.toLowerCase()}) THEN ${s.decimals}`); + const unlistedDecArms = [...prices.quoteTokens.entries()].flatMap(([key, meta]) => { + const [cid, address] = key.split(":"); + return meta.decimals !== null && meta.decimals >= 0 && meta.decimals <= 36 ? [db`WHEN (l.chain_id = ${Number(cid)} AND l.quote = ${address}) THEN ${meta.decimals}`] : []; + }); + stockDecArms.push(...unlistedDecArms); const decCase = stockDecArms.length ? stockDecArms.reduce((acc, c) => db`${acc} ${c}`) : db``; 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 = 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})`; + const volUsd = db`(${volCol} / power(10, ${qd}) * COALESCE(${usdPerUnit}, 0))`; + if (sort === "mcap") { + const rows = await mcapPageQuery(db, { where, quoteDecimals: qd, quoteUsd: usdPerUnit, mode: parseMcapMode(opts.mcap), limit, offset, select: SELECT }) as unknown as (Raw & McapColumns)[]; + const shaped = rows.map((r) => shape(r, ethUsd, prices)); + return { items: shaped.slice(0, limit), hasMore: shaped.length > limit }; + } const newest = db`l.block_time DESC, l.chain_id DESC, l.block_number DESC`; if (sort === "live") { const rows = await db` @@ -372,21 +402,19 @@ export async function listLaunchesPage(opts: ListOpts = {}): Promise { WHERE base.live_tier = 0 OR rest.rn = 1 ORDER BY base.live_tier, base.traders_1h_ex DESC, base.traders_24h_ex DESC, base.last_outside_trade_at DESC NULLS LAST, base.block_time DESC, base.chain_id DESC, base.block_number DESC LIMIT ${limit + 1} OFFSET ${offset}`; - const shaped = rows.map((r) => shape(r, ethUsd)); + const shaped = rows.map((r) => shape(r, ethUsd, prices)); return { items: shaped.slice(0, limit), hasMore: shaped.length > limit }; } const order = sort === "volume" ? db`ORDER BY ${volUsd} DESC, ${newest}` - : sort === "mcap" - ? db`ORDER BY ${mcapUsd} DESC, ${newest}` - : sort === "gainers" + : sort === "gainers" ? db`ORDER BY (l.start_tick - COALESCE(l.tick, l.start_tick)) DESC, ${newest}` : sort === "holders" ? db`ORDER BY l.holders DESC, ${newest}` : db`ORDER BY ${newest}`; const rows = await db`${db.unsafe(SELECT)} ${where} ${order} LIMIT ${limit + 1} OFFSET ${offset}`; - const shaped = rows.map((r) => shape(r, ethUsd)); + const shaped = rows.map((r) => shape(r, ethUsd, prices)); return { items: shaped.slice(0, limit), hasMore: shaped.length > limit }; } diff --git a/app/src/lib/launchpad/unlisted-quote.test.ts b/app/src/lib/launchpad/unlisted-quote.test.ts index f66ff512..8f021351 100644 --- a/app/src/lib/launchpad/unlisted-quote.test.ts +++ b/app/src/lib/launchpad/unlisted-quote.test.ts @@ -78,7 +78,7 @@ test("the server resolves unlisted quotes from bb_quote_tokens, fails soft, and const q = src("queries.ts"); assert.match(q, /return unlistedQuote\(address, st\.quoteTokens\.get\(`\$\{chainIdOf\(chain\)\}:\$\{address\.toLowerCase\(\)\}`\)/); assert.match(q, /const priceState = \(\): PriceState => \(\{[^\n]*quoteTokens: quoteTokensNow,/); - assert.match(q, /quoteTokensNow = await memo\("quote-tokens"/); + assert.match(q, /quoteTokens = new Map\(await memo\("quote-tokens"/); assert.match(q, /\/\* fail soft \(table not migrated yet/); assert.match(q, /rankTrending\(rows\.filter\(\(r\) => r\.quote_key !== "other"\)/); assert.match(q, /quote_decimals_known: q\.decimalsKnown !== false/);