From b6f5230969fcff2a4a6fd8ef97a75b4e1632847d Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Sat, 12 Sep 2026 18:32:44 +0530 Subject: [PATCH 1/2] fix(search): order cross-chain results by block time and escape LIKE wildcards - searchLaunches pre-limit and JS tiebreak used raw block numbers across chains, hiding exact matches on low-height chains; both now use block time (chain id, block number tiebreaks), matching the newestFirst invariant - findLaunchChain picks the newest row by block time, not height - escape %/_ in ILIKE patterns (ESCAPE '\') so they match literally - compareSearchHit + escapeLike are pure and unit-tested --- app/src/lib/launchpad/queries.ts | 12 +++++--- app/src/lib/launchpad/search.test.ts | 41 +++++++++++++++++++++++++++- app/src/lib/launchpad/search.ts | 23 ++++++++++++++++ 3 files changed, 71 insertions(+), 5 deletions(-) diff --git a/app/src/lib/launchpad/queries.ts b/app/src/lib/launchpad/queries.ts index fcffad3..a6f5007 100644 --- a/app/src/lib/launchpad/queries.ts +++ b/app/src/lib/launchpad/queries.ts @@ -11,7 +11,7 @@ import { SNIPER_BLOCKS } from "./holders"; import { imagePublicBase } from "./imageStore"; import { fdvQuote, quotePerToken, tickToTokensPerQuote, units } from "./math"; import type { RawCandle } from "./candles"; -import { normalizeQuery, isAddressQuery, rankHit, type LaunchFilter } from "./search"; +import { normalizeQuery, isAddressQuery, escapeLike, compareSearchHit, type LaunchFilter } from "./search"; /** * Read model for the UI. Every money field comes in two flavours: raw quote @@ -306,7 +306,7 @@ export async function getLaunch(chain: ChainKey, token: string, ethUsd: number | export async function findLaunchChain(token: string): Promise { const db = maybeDb(); if (!db) return null; - const rows = await db<{ chain_id: number }[]>`SELECT chain_id FROM bb_launches WHERE token = ${token.toLowerCase()} ORDER BY block_number DESC LIMIT 1`; + const rows = await db<{ chain_id: number }[]>`SELECT chain_id FROM bb_launches WHERE token = ${token.toLowerCase()} ORDER BY block_time DESC, chain_id DESC, block_number DESC LIMIT 1`; return rows[0] ? chainKeyOf(rows[0].chain_id) : null; } @@ -430,9 +430,13 @@ export async function searchLaunches(q: string, opts: { chain?: ChainKey | null; const chainCond = opts.chain ? db`AND l.chain_id = ${chainIdOf(opts.chain)}` : db``; const rows = isAddressQuery(n) ? await db`${db.unsafe(SELECT)} WHERE l.token = ${n} ${chainCond}` - : await db`${db.unsafe(SELECT)} WHERE (l.name ILIKE ${"%" + n + "%"} OR l.symbol ILIKE ${"%" + n + "%"}) ${chainCond} ORDER BY l.block_number DESC LIMIT 200`; + // LIKE metacharacters in n are escaped (ESCAPE '\'): a search for "%" or + // "_" matches those literal characters, not every row. Pre-limit orders by + // block time — never raw block numbers across chains (Base heights dwarf + // Robinhood's, so the old ORDER BY hid exact matches on the low chain). + : await db`${db.unsafe(SELECT)} WHERE (l.name ILIKE ${"%" + escapeLike(n) + "%"} ESCAPE '\' OR l.symbol ILIKE ${"%" + escapeLike(n) + "%"} ESCAPE '\') ${chainCond} ORDER BY l.block_time DESC, l.chain_id DESC, l.block_number DESC LIMIT 200`; const shaped = rows.map((r) => shape(r, opts.ethUsd ?? null)); - shaped.sort((a, b) => rankHit(a, n) - rankHit(b, n) || b.block_number - a.block_number); + shaped.sort((a, b) => compareSearchHit(a, b, n)); return shaped.slice(0, limit); } diff --git a/app/src/lib/launchpad/search.test.ts b/app/src/lib/launchpad/search.test.ts index 257f16d..274042e 100644 --- a/app/src/lib/launchpad/search.test.ts +++ b/app/src/lib/launchpad/search.test.ts @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { isAddressQuery, matchesFilter, matchesQuery, normalizeQuery, rankHit } from "./search.ts"; +import { compareSearchHit, escapeLike, isAddressQuery, matchesFilter, matchesQuery, normalizeQuery, rankHit, type RankedHit } from "./search.ts"; const row = (o: Partial[0]> = {}) => ({ name: "Clear Sky", @@ -52,3 +52,42 @@ test("rankHit orders symbol exact < symbol prefix < name prefix < substring", () assert.equal(rankHit(row(), "clear"), 2); assert.equal(rankHit(row(), "ear"), 3); }); + +test("escapeLike neutralizes LIKE wildcards and the escape char", () => { + assert.equal(escapeLike("abc"), "abc"); + assert.equal(escapeLike("a%b"), "a\\%b"); + assert.equal(escapeLike("a_b"), "a\\_b"); + assert.equal(escapeLike("a\\b"), "a\\\\b"); + assert.equal(escapeLike("%_%"), "\\%\\_\\%"); +}); + +const hit = (o: Partial = {}): RankedHit => ({ + ...row(), + chain_id: 8453, + block_number: 30_000_000, + ...o, +}); + +test("compareSearchHit: relevance first, then cross-chain newest-first", () => { + const exactLowChain = hit({ symbol: "FOO", chain_id: 7777777, block_number: 5_000, block_time: "2026-09-11T23:59:00.000Z" }); + const substringHighChain = hit({ symbol: "XFOOX", name: "Xfoox thing", chain_id: 8453, block_number: 30_000_000, block_time: "2026-09-12T00:00:00.000Z" }); + assert.ok(compareSearchHit(exactLowChain, substringHighChain, "foo") < 0, "exact match on a low-height chain beats a newer substring match"); + assert.ok(compareSearchHit(substringHighChain, exactLowChain, "foo") > 0); +}); + +test("compareSearchHit: same rank orders by block time, not raw block number", () => { + const olderHighHeight = hit({ symbol: "FOOA", chain_id: 8453, block_number: 30_000_000, block_time: "2026-09-11T00:00:00.000Z" }); + const newerLowHeight = hit({ symbol: "FOOB", chain_id: 7777777, block_number: 5_000, block_time: "2026-09-12T00:00:00.000Z" }); + assert.ok(compareSearchHit(newerLowHeight, olderHighHeight, "foo") < 0, "newer launch wins despite a far lower block number"); + const sorted = [olderHighHeight, newerLowHeight].sort((a, b) => compareSearchHit(a, b, "foo")); + assert.deepEqual(sorted.map((r) => r.symbol), ["FOOB", "FOOA"]); +}); + +test("compareSearchHit: rank dominates time; ties break deterministically", () => { + const newerSubstring = hit({ symbol: "XFOOX", name: "Xfoox", block_time: "2026-09-12T00:00:00.000Z", block_number: 30_000_001 }); + const olderPrefix = hit({ symbol: "FOOX", block_time: "2026-09-11T00:00:00.000Z", block_number: 30_000_000 }); + assert.ok(compareSearchHit(olderPrefix, newerSubstring, "foo") < 0, "prefix beats newer substring"); + const a = hit({ symbol: "FOO", block_time: "2026-09-12T00:00:00.000Z", chain_id: 8453, block_number: 7 }); + const b = hit({ symbol: "FOO", block_time: "2026-09-12T00:00:00.000Z", chain_id: 8453, block_number: 7 }); + assert.equal(compareSearchHit(a, b, "foo"), 0); +}); diff --git a/app/src/lib/launchpad/search.ts b/app/src/lib/launchpad/search.ts index 4937adc..48b935c 100644 --- a/app/src/lib/launchpad/search.ts +++ b/app/src/lib/launchpad/search.ts @@ -1,4 +1,5 @@ /** Pure search / filter helpers (client + server; node --test loads this directly). */ +import { newestFirst } from "./paging"; export type LaunchFilter = "fee0" | "burn" | "usdg" | "gitlawb" | "today"; /** `chain`: the filter only makes sense on that chain (its quote is not offered elsewhere) → hidden when another chain is selected. */ export const FILTERS: { key: LaunchFilter; label: string; title: string; chain?: "base" | "robinhood" }[] = [ @@ -50,3 +51,25 @@ export function rankHit(l: Matchable, q: string): number { if (name.startsWith(n)) return 2; return 3; } + +/** + * Escape user input for a Postgres LIKE/ILIKE pattern (`%`, `_` and the escape + * char itself). Without this, searching for `%` or `_` matches nearly every + * row instead of the literal character. + */ +export function escapeLike(s: string): string { + return s.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_"); +} + +/** A search hit with the cross-chain ordering keys (block numbers are only comparable within one chain). */ +export type RankedHit = Matchable & { chain_id: number; block_number: number }; + +/** + * Full search ordering: relevance rank first, then cross-chain newest-first + * (block time, chain id, block number — never raw block numbers across + * chains, mirroring the list sorts). The SQL pre-limit must use the same key + * or exact matches on a low-height chain never reach the ranker. + */ +export function compareSearchHit(a: RankedHit, b: RankedHit, q: string): number { + return rankHit(a, q) - rankHit(b, q) || newestFirst(a, b); +} From e043cf52ec5a73ec188a3250658eac162d18b3af Mon Sep 17 00:00:00 2001 From: Ayush7614 Date: Sun, 13 Sep 2026 10:45:35 +0530 Subject: [PATCH 2/2] fix(search): double backslash in ESCAPE clause so SQL receives ESCAPE '\' JS template cooked text consumes a single backslash, so ESCAPE '\' in source was cooking to ESCAPE '' (empty escape) and the helper's backslashes were ignored. Using ESCAPE '\\' in source cooks to ESCAPE '\' in SQL, matching escapeLike('\%','\_','\\'). Verified: search.test.ts 9/9; query-construction check shows ESCAPE '\' in cooked SQL. --- app/src/lib/launchpad/queries.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/lib/launchpad/queries.ts b/app/src/lib/launchpad/queries.ts index a6f5007..43fc871 100644 --- a/app/src/lib/launchpad/queries.ts +++ b/app/src/lib/launchpad/queries.ts @@ -434,7 +434,7 @@ export async function searchLaunches(q: string, opts: { chain?: ChainKey | null; // "_" matches those literal characters, not every row. Pre-limit orders by // block time — never raw block numbers across chains (Base heights dwarf // Robinhood's, so the old ORDER BY hid exact matches on the low chain). - : await db`${db.unsafe(SELECT)} WHERE (l.name ILIKE ${"%" + escapeLike(n) + "%"} ESCAPE '\' OR l.symbol ILIKE ${"%" + escapeLike(n) + "%"} ESCAPE '\') ${chainCond} ORDER BY l.block_time DESC, l.chain_id DESC, l.block_number DESC LIMIT 200`; + : await db`${db.unsafe(SELECT)} WHERE (l.name ILIKE ${"%" + escapeLike(n) + "%"} ESCAPE '\\' OR l.symbol ILIKE ${"%" + escapeLike(n) + "%"} ESCAPE '\\') ${chainCond} ORDER BY l.block_time DESC, l.chain_id DESC, l.block_number DESC LIMIT 200`; const shaped = rows.map((r) => shape(r, opts.ethUsd ?? null)); shaped.sort((a, b) => compareSearchHit(a, b, n)); return shaped.slice(0, limit);