Skip to content

DocsReading Dawnscan

Search

How search keeps published projects and launch records visibly apart.

The search box on the homepage and on /search finds two kinds of thing, kept visibly apart: published projects and launch records. This document is the contract for both and for the autocomplete in front of them.

1. The identity rule

A token's identity is (chain_id, contract_address). A name or a ticker is how a reader finds a record; it is never what the record is. Consequences:

  • A ticker search returns every published project that shares it. Two projects with the ticker ALPHA are two results, not one.
  • Only a full contract address (0x + 40 hex) is treated as an identity: it may reach the whole discovered universe, hidden records included, and ranks first.
  • A partial address (0x + 4–39 hex) is a prefix hint within the published catalogue. 0x alone matches nothing rather than everything.
  • Every suggestion and every launch record carries the contract; a suggestion for a launch record navigates to the search page anchored on that contract (/search?q=0x…), never to a page named after a ticker.

2. Published projects (searchProjects)

Unchanged scope: VISIBLE_PROJECT (approved, published, not a demo fixture in real mode). Matching, in rank order:

Rank Match
0 exact contract address (any case)
1 exact ticker (any case)
2 name prefix
3 ticker prefix, or contract prefix
4 name / slug / narrative contains

LIKE wildcards in the query (%, _) are escaped and mean themselves.

3. Launch records (searchLaunchRecords)

Hidden candidates — catalog_status = 'HIDDEN_CANDIDATE' within ANY_DISCOVERED_PROJECT (approved, is_demo = false in real mode) — that carry a primary token with a name or a ticker. Matched by project name or token ticker (contains), ranked exact ticker → name prefix → ticker prefix → the rest, newest first, at most 20 on the search page.

What a record shows, and where it comes from:

Fact Source When absent
Name projects.name never
Ticker tokens.symbol omitted
Contract tokens.chain_id + tokens.contract_address, with the copy / explorer control the cards use never
Launched via projects.launchpad_version, else the candidate's launchpad for the same (chain, contract) (launchedViaFrom) "Unknown"
Market cap the cards' tokenMarketReading (freshest stored reading, ≤ 7 days, with its source) omitted — never a launchpad figure, never $0
Label Launch record — not researched by Dawnscan never

Nothing else: no project link, no activity status, no description, no score. Dawnscan has indexed the launch and not researched it, and the row says exactly that.

An exact contract address is answered by searchProjects (as a hidden card, catalogStatus = 'HIDDEN_CANDIDATE'), so searchLaunchRecords returns nothing for one and searchCatalogue drops any launch record whose token is already in the published list. One token, one row.

Cost

The name/ticker OR spans two tables, so PostgreSQL hash-joins hidden projects to their tokens rather than using projects_name_trgm_idx / tokens_symbol_trgm_idx (migration 0002). EXPLAIN ANALYZE on 100k synthetic hidden records: ~50 ms, flat for any query. A UNION of two trigram scans was measured and is faster only for selective queries (25 ms) while a three-character query took 4× longer, so the plain shape stays and no migration was added.

4. Suggestions (GET /api/search/suggest?q=)

  • q: Zod-validated after control characters are stripped (NUL-safe); 2–64 characters, else 400. Rate-limited per client address (api.search.suggest, 240/min). Cache-Control: public, max-age=30.
  • Answer: { q, suggestions }, at most 8 items, published projects first (in search order), then launch records. Each item is typed:
{ type: 'project', name, symbol?, contract?, target: '/project/<slug>' }
{ type: 'launch',  name, symbol?, contract, launchedVia, target: '/search?q=0x…' }

A hidden card returned by an exact-contract search is typed launch. The same token never appears twice. Reads Dawnscan's database only; no provider is contacted.

5. The box (SearchField)

Progressive enhancement. Server-rendered, it is the plain GET form to /search it always was — no JavaScript needed. Since 2026-09-05 the same field is also each listing's own box (action = the listing, variant="compact", hidden inputs for the listing's other parameters), so q narrows Explore, Ships, Radar and a narrative page in place — see docs/LISTING_CONTROLS.md, which also covers the search page's own sort and has. Once mounted it becomes an ARIA combobox:

  • after 2 characters and a 150 ms pause it asks the suggest route (in-flight requests are aborted when the reader keeps typing);
  • role="combobox", aria-autocomplete="list", aria-expanded, aria-controls, aria-activedescendant; the list is role="listbox" with role="option" items; a polite live region announces the count;
  • Up / Down move (wrapping through "nothing chosen"), Enter on a suggestion goes to its target, Enter with nothing chosen submits the form, Escape closes, Tab and blur close;
  • a failed lookup only means no list — the form still submits.

Visual language is V6: one subtle border, surface background, no shadows beyond shadow-sm, no glass. At 375 px the list spans the field and truncates; the "via " detail is hidden below sm.

6. Fixtures and tests

  • hidden-lantern ($LNTN, 0xe0…e5, Virtuals launch record) in packages/db/src/seed/demo.ts is the hidden fixture; visible only with DATA_MODE=demo, and only on the search page.
  • Unit: packages/domain/src/queries/search.test.ts (ranking / typing), apps/web/src/lib/search-suggest.test.ts (validation, headers, rate limit).
  • Integration: packages/domain/src/queries/public.integration.test.ts ("search", "launch records").
  • Playwright: apps/web/e2e/search.spec.ts.