2026-04-10 12:52:17 -07:00
|
|
|
import { getGruCatalogPosture } from '@/services/api/gruCatalog'
|
|
|
|
|
|
2026-04-07 23:22:12 -07:00
|
|
|
export type DirectSearchTarget =
|
|
|
|
|
| { kind: 'address'; href: string; label: string }
|
|
|
|
|
| { kind: 'transaction'; href: string; label: string }
|
|
|
|
|
| { kind: 'block'; href: string; label: string }
|
2026-04-10 12:52:17 -07:00
|
|
|
| { kind: 'token'; href: string; label: string }
|
|
|
|
|
|
|
|
|
|
export interface SearchTokenHint {
|
|
|
|
|
chainId?: number
|
|
|
|
|
symbol?: string
|
|
|
|
|
address?: string
|
|
|
|
|
name?: string
|
|
|
|
|
tags?: string[]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface RawExplorerSearchItem {
|
|
|
|
|
type?: string | null
|
|
|
|
|
address?: string | null
|
|
|
|
|
block_number?: number | string | null
|
|
|
|
|
transaction_hash?: string | null
|
|
|
|
|
priority?: number | null
|
|
|
|
|
name?: string | null
|
|
|
|
|
symbol?: string | null
|
|
|
|
|
token_type?: string | null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export interface ExplorerSearchResult {
|
|
|
|
|
type: string
|
|
|
|
|
chain_id: number
|
|
|
|
|
data: {
|
|
|
|
|
hash?: string
|
|
|
|
|
address?: string
|
|
|
|
|
number?: number
|
|
|
|
|
}
|
|
|
|
|
score: number
|
|
|
|
|
href?: string
|
|
|
|
|
label: string
|
|
|
|
|
name?: string
|
|
|
|
|
symbol?: string
|
|
|
|
|
token_type?: string
|
|
|
|
|
is_curated_token?: boolean
|
|
|
|
|
is_gru_token?: boolean
|
|
|
|
|
is_x402_ready?: boolean
|
|
|
|
|
is_wrapped_transport?: boolean
|
|
|
|
|
currency_code?: string
|
|
|
|
|
match_reason?: string
|
|
|
|
|
matched_tags?: string[]
|
|
|
|
|
}
|
2026-04-07 23:22:12 -07:00
|
|
|
|
|
|
|
|
const addressPattern = /^0x[a-f0-9]{40}$/i
|
|
|
|
|
const transactionHashPattern = /^0x[a-f0-9]{64}$/i
|
|
|
|
|
const blockNumberPattern = /^\d+$/
|
|
|
|
|
|
|
|
|
|
export function inferDirectSearchTarget(query: string): DirectSearchTarget | null {
|
|
|
|
|
const trimmed = query.trim()
|
|
|
|
|
if (!trimmed) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (addressPattern.test(trimmed)) {
|
|
|
|
|
return {
|
|
|
|
|
kind: 'address',
|
|
|
|
|
href: `/addresses/0x${trimmed.slice(2)}`,
|
|
|
|
|
label: 'Open address',
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (transactionHashPattern.test(trimmed)) {
|
|
|
|
|
return {
|
|
|
|
|
kind: 'transaction',
|
|
|
|
|
href: `/transactions/0x${trimmed.slice(2)}`,
|
|
|
|
|
label: 'Open transaction',
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (blockNumberPattern.test(trimmed)) {
|
|
|
|
|
return {
|
|
|
|
|
kind: 'block',
|
|
|
|
|
href: `/blocks/${trimmed}`,
|
|
|
|
|
label: 'Open block',
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return null
|
|
|
|
|
}
|
2026-04-10 12:52:17 -07:00
|
|
|
|
|
|
|
|
export function inferTokenSearchTarget(query: string, tokens: SearchTokenHint[] = []): DirectSearchTarget | null {
|
|
|
|
|
const trimmed = query.trim()
|
|
|
|
|
if (!trimmed) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const lower = trimmed.toLowerCase()
|
|
|
|
|
const matched = tokens.find((token) => {
|
|
|
|
|
if (token.chainId !== 138) return false
|
|
|
|
|
return token.address?.toLowerCase() === lower || token.symbol?.toLowerCase() === lower
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if (!matched?.address) {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
kind: 'token',
|
|
|
|
|
href: `/tokens/${matched.address}`,
|
|
|
|
|
label: `Open token${matched.symbol ? ` (${matched.symbol})` : ''}`,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizeNumber(value: string | number | null | undefined): number | undefined {
|
|
|
|
|
if (typeof value === 'number' && Number.isFinite(value)) {
|
|
|
|
|
return value
|
|
|
|
|
}
|
|
|
|
|
if (typeof value === 'string' && value.trim()) {
|
|
|
|
|
const parsed = Number(value)
|
|
|
|
|
if (Number.isFinite(parsed)) {
|
|
|
|
|
return parsed
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getTypeWeight(type: string): number {
|
|
|
|
|
switch (type) {
|
|
|
|
|
case 'token':
|
|
|
|
|
return 40
|
|
|
|
|
case 'transaction':
|
|
|
|
|
return 30
|
|
|
|
|
case 'address':
|
|
|
|
|
return 20
|
|
|
|
|
case 'block':
|
|
|
|
|
return 10
|
|
|
|
|
default:
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getExactnessBoost(query: string, item: RawExplorerSearchItem): number {
|
|
|
|
|
const trimmed = query.trim().toLowerCase()
|
|
|
|
|
if (!trimmed) {
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const candidates = [
|
|
|
|
|
item.address?.toLowerCase(),
|
|
|
|
|
item.transaction_hash?.toLowerCase(),
|
|
|
|
|
item.symbol?.toLowerCase(),
|
|
|
|
|
item.name?.toLowerCase(),
|
|
|
|
|
normalizeNumber(item.block_number)?.toString(),
|
|
|
|
|
].filter((value): value is string => Boolean(value))
|
|
|
|
|
|
|
|
|
|
return candidates.includes(trimmed) ? 1000 : 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getCuratedMatchReason(query: string, token?: SearchTokenHint): string | undefined {
|
|
|
|
|
if (!token) return undefined
|
|
|
|
|
const trimmed = query.trim().toLowerCase()
|
|
|
|
|
if (!trimmed) return undefined
|
|
|
|
|
if (token.address?.toLowerCase() === trimmed) return 'exact curated token address'
|
|
|
|
|
if (token.symbol?.toLowerCase() === trimmed) return 'exact curated token symbol'
|
|
|
|
|
if (token.name?.toLowerCase() === trimmed) return 'exact curated token name'
|
|
|
|
|
if (token.symbol?.toLowerCase().includes(trimmed)) return 'symbol match'
|
|
|
|
|
if (token.name?.toLowerCase().includes(trimmed)) return 'name match'
|
|
|
|
|
if (token.tags?.some((tag) => tag.toLowerCase().includes(trimmed))) return 'tag match'
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getHref(type: string, item: RawExplorerSearchItem, curatedToken: SearchTokenHint | undefined): string | undefined {
|
|
|
|
|
if ((type === 'token' || curatedToken) && item.address) {
|
|
|
|
|
return `/tokens/${item.address}`
|
|
|
|
|
}
|
|
|
|
|
if (type === 'address' && item.address) {
|
|
|
|
|
return `/addresses/${item.address}`
|
|
|
|
|
}
|
|
|
|
|
if (type === 'transaction' && item.transaction_hash) {
|
|
|
|
|
return `/transactions/${item.transaction_hash}`
|
|
|
|
|
}
|
|
|
|
|
const blockNumber = normalizeNumber(item.block_number)
|
|
|
|
|
if (type === 'block' && blockNumber != null) {
|
|
|
|
|
return `/blocks/${blockNumber}`
|
|
|
|
|
}
|
|
|
|
|
return undefined
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getLabel(type: string, item: RawExplorerSearchItem, curatedToken: SearchTokenHint | undefined): string {
|
|
|
|
|
if ((type === 'token' || curatedToken) && item.symbol) {
|
|
|
|
|
return `Token${item.symbol ? ` · ${item.symbol}` : ''}`
|
|
|
|
|
}
|
|
|
|
|
if ((type === 'token' || curatedToken) && item.name) {
|
|
|
|
|
return 'Token'
|
|
|
|
|
}
|
|
|
|
|
switch (type) {
|
|
|
|
|
case 'transaction':
|
|
|
|
|
return 'Transaction'
|
|
|
|
|
case 'block':
|
|
|
|
|
return 'Block'
|
|
|
|
|
case 'address':
|
|
|
|
|
return 'Address'
|
|
|
|
|
default:
|
|
|
|
|
return 'Search Result'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getDeduplicationKey(type: string, item: RawExplorerSearchItem): string {
|
|
|
|
|
if ((type === 'token' || type === 'address') && item.address) {
|
|
|
|
|
return `entity:${item.address.toLowerCase()}`
|
|
|
|
|
}
|
|
|
|
|
if (type === 'transaction' && item.transaction_hash) {
|
|
|
|
|
return `tx:${item.transaction_hash.toLowerCase()}`
|
|
|
|
|
}
|
|
|
|
|
const blockNumber = normalizeNumber(item.block_number)
|
|
|
|
|
if (type === 'block' && blockNumber != null) {
|
|
|
|
|
return `block:${blockNumber}`
|
|
|
|
|
}
|
|
|
|
|
return `${type}:${item.address || item.transaction_hash || item.block_number || item.name || item.symbol || 'unknown'}`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function normalizeExplorerSearchResults(
|
|
|
|
|
query: string,
|
|
|
|
|
items: RawExplorerSearchItem[] = [],
|
|
|
|
|
tokens: SearchTokenHint[] = [],
|
|
|
|
|
): ExplorerSearchResult[] {
|
|
|
|
|
const curatedLookup = new Map<string, SearchTokenHint>()
|
|
|
|
|
for (const token of tokens) {
|
|
|
|
|
if (token.chainId !== 138 || !token.address) continue
|
|
|
|
|
curatedLookup.set(token.address.toLowerCase(), token)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const deduped = new Map<string, ExplorerSearchResult & { _ranking: number }>()
|
|
|
|
|
|
|
|
|
|
for (const item of items) {
|
|
|
|
|
const type = item.type || 'unknown'
|
|
|
|
|
const blockNumber = normalizeNumber(item.block_number)
|
|
|
|
|
const curatedToken = item.address ? curatedLookup.get(item.address.toLowerCase()) : undefined
|
|
|
|
|
const normalizedType = type === 'address' && curatedToken ? 'token' : type
|
|
|
|
|
const gruPosture =
|
|
|
|
|
normalizedType === 'token' || normalizedType === 'address'
|
|
|
|
|
? getGruCatalogPosture({
|
|
|
|
|
symbol: item.symbol || curatedToken?.symbol,
|
|
|
|
|
address: item.address,
|
|
|
|
|
tags: curatedToken?.tags,
|
|
|
|
|
})
|
|
|
|
|
: null
|
|
|
|
|
const ranking =
|
|
|
|
|
getExactnessBoost(query, item) +
|
|
|
|
|
(item.priority ?? 0) * 10 +
|
|
|
|
|
getTypeWeight(normalizedType) +
|
|
|
|
|
(curatedToken ? 15 : 0) +
|
|
|
|
|
(gruPosture?.isGru ? 8 : 0) +
|
|
|
|
|
(gruPosture?.isX402Ready ? 5 : 0)
|
|
|
|
|
|
|
|
|
|
const result: ExplorerSearchResult & { _ranking: number } = {
|
|
|
|
|
type: normalizedType,
|
|
|
|
|
chain_id: 138,
|
|
|
|
|
data: {
|
|
|
|
|
hash: item.transaction_hash || undefined,
|
|
|
|
|
address: item.address || undefined,
|
|
|
|
|
number: blockNumber,
|
|
|
|
|
},
|
|
|
|
|
score: item.priority ?? 0,
|
|
|
|
|
href: getHref(normalizedType, item, curatedToken),
|
|
|
|
|
label: getLabel(normalizedType, item, curatedToken),
|
|
|
|
|
name: item.name || curatedToken?.name || undefined,
|
|
|
|
|
symbol: item.symbol || curatedToken?.symbol || undefined,
|
|
|
|
|
token_type: item.token_type || undefined,
|
|
|
|
|
is_curated_token: Boolean(curatedToken),
|
|
|
|
|
is_gru_token: gruPosture?.isGru || false,
|
|
|
|
|
is_x402_ready: gruPosture?.isX402Ready || false,
|
|
|
|
|
is_wrapped_transport: gruPosture?.isWrappedTransport || false,
|
|
|
|
|
currency_code: gruPosture?.currencyCode,
|
|
|
|
|
match_reason:
|
|
|
|
|
getCuratedMatchReason(query, curatedToken) ||
|
|
|
|
|
(getExactnessBoost(query, item) > 0 ? 'exact match' : undefined),
|
|
|
|
|
matched_tags: curatedToken?.tags?.filter((tag) => tag.toLowerCase().includes(query.trim().toLowerCase())) || [],
|
|
|
|
|
_ranking: ranking,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const key = getDeduplicationKey(normalizedType, item)
|
|
|
|
|
const existing = deduped.get(key)
|
|
|
|
|
if (!existing || result._ranking > existing._ranking) {
|
|
|
|
|
deduped.set(key, result)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Array.from(deduped.values())
|
|
|
|
|
.sort((left, right) => right._ranking - left._ranking)
|
|
|
|
|
.map(({ _ranking, ...result }) => result)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function suggestCuratedTokens(query: string, tokens: SearchTokenHint[] = []): SearchTokenHint[] {
|
|
|
|
|
const trimmed = query.trim().toLowerCase()
|
|
|
|
|
if (!trimmed) return []
|
|
|
|
|
|
|
|
|
|
return tokens
|
|
|
|
|
.filter((token) => token.chainId === 138)
|
|
|
|
|
.filter((token) =>
|
|
|
|
|
token.symbol?.toLowerCase().includes(trimmed) ||
|
|
|
|
|
token.name?.toLowerCase().includes(trimmed) ||
|
|
|
|
|
token.tags?.some((tag) => tag.toLowerCase().includes(trimmed)),
|
|
|
|
|
)
|
|
|
|
|
.sort((left, right) => {
|
|
|
|
|
const leftExact = left.symbol?.toLowerCase() === trimmed || left.name?.toLowerCase() === trimmed
|
|
|
|
|
const rightExact = right.symbol?.toLowerCase() === trimmed || right.name?.toLowerCase() === trimmed
|
|
|
|
|
if (leftExact !== rightExact) return leftExact ? -1 : 1
|
|
|
|
|
return (left.symbol || left.name || '').localeCompare(right.symbol || right.name || '')
|
|
|
|
|
})
|
|
|
|
|
.slice(0, 5)
|
|
|
|
|
}
|