import { getGruCatalogPosture } from '@/services/api/gruCatalog' export type DirectSearchTarget = | { kind: 'address'; href: string; label: string } | { kind: 'transaction'; href: string; label: string } | { kind: 'block'; href: string; label: string } | { 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[] } 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 } 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() for (const token of tokens) { if (token.chainId !== 138 || !token.address) continue curatedLookup.set(token.address.toLowerCase(), token) } const deduped = new Map() 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) }