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
ALPHAare 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.0xalone 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, else400. 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 isrole="listbox"withrole="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 sm.
6. Fixtures and tests
hidden-lantern($LNTN,0xe0…e5, Virtuals launch record) inpackages/db/src/seed/demo.tsis the hidden fixture; visible only withDATA_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.