- 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.
310 lines
9.4 KiB
TypeScript
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)
|
|
}
|