Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 8 additions & 4 deletions app/src/lib/launchpad/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -306,7 +306,7 @@ export async function getLaunch(chain: ChainKey, token: string, ethUsd: number |
export async function findLaunchChain(token: string): Promise<ChainKey | null> {
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;
}

Expand Down Expand Up @@ -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<Raw[]>`${db.unsafe(SELECT)} WHERE l.token = ${n} ${chainCond}`
: await db<Raw[]>`${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<Raw[]>`${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);
}

Expand Down
41 changes: 40 additions & 1 deletion app/src/lib/launchpad/search.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { 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<Parameters<typeof matchesQuery>[0]> = {}) => ({
name: "Clear Sky",
Expand Down Expand Up @@ -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> = {}): 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);
});
23 changes: 23 additions & 0 deletions app/src/lib/launchpad/search.ts
Original file line number Diff line number Diff line change
@@ -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" }[] = [
Expand Down Expand Up @@ -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);
}
Loading