Files
explorer-monorepo/frontend/src/utils/search.ts
defiQUG 0972178cc5 refactor: rename SolaceScanScout to Solace and update related configurations
- Updated branding from "SolaceScanScout" to "Solace" across various files including deployment scripts, API responses, and documentation.
- Changed default base URL for Playwright tests and updated security headers to reflect the new branding.
- Enhanced README and API documentation to include new authentication endpoints and product access details.

This refactor aligns the project branding and improves clarity in the API documentation.
2026-04-10 12:52:17 -07:00

310 lines
9.4 KiB
TypeScript

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<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)
}