feat(explorer): dual-chain wallet metadata, native coin pricing, and UI refresh.
Add Chain 138 wallet network metadata and stats coin-price enrichment; sync frontend explorer SPA, command center, and address/token pages with backend config. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
|
||||
import Navbar from './Navbar'
|
||||
import Footer from './Footer'
|
||||
import ExplorerAgentTool from './ExplorerAgentTool'
|
||||
import ExplorerDocumentHead from './ExplorerDocumentHead'
|
||||
import { UiModeProvider } from './UiModeContext'
|
||||
import { PostureGlossaryProvider } from './PostureGlossaryProvider'
|
||||
|
||||
@@ -9,6 +10,7 @@ export default function ExplorerChrome({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<UiModeProvider>
|
||||
<PostureGlossaryProvider>
|
||||
<ExplorerDocumentHead />
|
||||
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
|
||||
<a
|
||||
href="#main-content"
|
||||
|
||||
56
frontend/src/components/common/ExplorerDocumentHead.tsx
Normal file
56
frontend/src/components/common/ExplorerDocumentHead.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import Head from 'next/head'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
const BASE_TITLE = 'DBIS Explorer'
|
||||
|
||||
function resolveDocumentTitle(pathname: string, query: Record<string, string | string[] | undefined>): string {
|
||||
if (pathname === '/') return `${BASE_TITLE} — Chain 138`
|
||||
if (pathname === '/search') return `Search — ${BASE_TITLE}`
|
||||
if (pathname === '/wallet') return `Wallet Tools — ${BASE_TITLE}`
|
||||
if (pathname === '/protocols') return `Official Protocol Contracts — ${BASE_TITLE}`
|
||||
if (pathname.startsWith('/protocols/')) {
|
||||
const id = typeof query.id === 'string' ? query.id : pathname.split('/').pop() || 'Protocol'
|
||||
return `${id} — Protocols — ${BASE_TITLE}`
|
||||
}
|
||||
if (pathname === '/pools') return `Pool Registry — ${BASE_TITLE}`
|
||||
if (pathname.startsWith('/pools/')) return `Pool Detail — ${BASE_TITLE}`
|
||||
if (pathname === '/liquidity') return `Liquidity — ${BASE_TITLE}`
|
||||
if (pathname === '/operations') return `Operations — ${BASE_TITLE}`
|
||||
if (pathname === '/docs') return `Documentation — ${BASE_TITLE}`
|
||||
if (pathname === '/tokens') return `Tokens — ${BASE_TITLE}`
|
||||
if (pathname.startsWith('/tokens/')) return `Token — ${BASE_TITLE}`
|
||||
if (pathname === '/addresses') return `Addresses — ${BASE_TITLE}`
|
||||
if (pathname.startsWith('/addresses/')) return `Address — ${BASE_TITLE}`
|
||||
if (pathname === '/blocks') return `Blocks — ${BASE_TITLE}`
|
||||
if (pathname === '/transactions') return `Transactions — ${BASE_TITLE}`
|
||||
if (pathname.startsWith('/transactions/')) return `Transaction — ${BASE_TITLE}`
|
||||
if (pathname === '/bridge') return `Bridge — ${BASE_TITLE}`
|
||||
if (pathname === '/routes') return `Routes — ${BASE_TITLE}`
|
||||
if (pathname === '/analytics') return `Analytics — ${BASE_TITLE}`
|
||||
if (pathname === '/operator') return `Operator — ${BASE_TITLE}`
|
||||
if (pathname === '/system') return `System — ${BASE_TITLE}`
|
||||
if (pathname === '/weth') return `WETH — ${BASE_TITLE}`
|
||||
if (pathname === '/watchlist') return `Watchlist — ${BASE_TITLE}`
|
||||
if (pathname === '/access') return `Account Access — ${BASE_TITLE}`
|
||||
|
||||
const segment = pathname.split('/').filter(Boolean)[0]
|
||||
if (segment) {
|
||||
const label = segment.charAt(0).toUpperCase() + segment.slice(1)
|
||||
return `${label} — ${BASE_TITLE}`
|
||||
}
|
||||
return BASE_TITLE
|
||||
}
|
||||
|
||||
export default function ExplorerDocumentHead() {
|
||||
const router = useRouter()
|
||||
const pathname = router.pathname || '/'
|
||||
const title = resolveDocumentTitle(pathname, router.query)
|
||||
|
||||
return (
|
||||
<Head>
|
||||
<title>{title}</title>
|
||||
</Head>
|
||||
)
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export default function Footer() {
|
||||
<li><Link className={footerLinkClass} href="/routes">Routes</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/liquidity">Liquidity</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/pools">Pools</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/protocols">Protocols</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/analytics">Analytics</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/operator">Operator</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/system">System</Link></li>
|
||||
@@ -87,9 +88,9 @@ export default function Footer() {
|
||||
</p>
|
||||
<p>
|
||||
Command center:{' '}
|
||||
<a className={footerLinkClass} href="/chain138-command-center.html" target="_blank" rel="noopener noreferrer">
|
||||
<Link className={footerLinkClass} href="/topology">
|
||||
Chain 138 visual map
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
<p className="text-xs leading-5 text-gray-500 dark:text-gray-500">
|
||||
Questions about the explorer, chain metadata, route discovery, or liquidity access
|
||||
|
||||
@@ -490,6 +490,7 @@ export default function Navbar() {
|
||||
pathname.startsWith('/tokens') ||
|
||||
pathname.startsWith('/analytics') ||
|
||||
pathname.startsWith('/pools') ||
|
||||
pathname.startsWith('/protocols') ||
|
||||
pathname.startsWith('/watchlist')
|
||||
const isOperationsActive =
|
||||
pathname.startsWith('/bridge') ||
|
||||
@@ -593,6 +594,7 @@ export default function Navbar() {
|
||||
{ href: '/tokens', label: 'Tokens', description: 'Review curated assets, standards, and token detail pages.' },
|
||||
{ href: '/analytics', label: 'Analytics', description: 'Open explorer-visible transaction and block activity summaries.' },
|
||||
{ href: '/pools', label: 'Pools', description: 'Browse mission-control pool inventory and route-backed liquidity context.' },
|
||||
{ href: '/protocols', label: 'Protocols', description: 'Review official upstream protocol contracts and production guardrails on Chain 138.' },
|
||||
{ href: '/watchlist', label: 'Watchlist', description: 'Jump into tracked addresses and saved explorer entities.' },
|
||||
],
|
||||
[],
|
||||
@@ -603,10 +605,11 @@ export default function Navbar() {
|
||||
{ href: '/bridge', label: 'Bridge', description: 'Inspect relay lanes, queue posture, and bridge trace tooling.' },
|
||||
{ href: '/routes', label: 'Routes', description: 'Review live route coverage, same-chain lanes, and bridge paths.' },
|
||||
{ href: '/liquidity', label: 'Liquidity', description: 'Check planner-backed route access and live liquidity posture.' },
|
||||
{ href: '/protocols', label: 'Protocols', description: 'Official DODO, UniV2/V3, CCIP, and integration contracts on Chain 138.' },
|
||||
{ href: '/system', label: 'System', description: 'Inspect topology, RPC capability, and public integration inventory.' },
|
||||
{ href: '/operator', label: 'Operator', description: 'Open planner, route, and relay shortcuts in one public page.' },
|
||||
{ href: '/weth', label: 'WETH', description: 'Review wrapped-asset references and bridge-oriented WETH context.' },
|
||||
{ href: '/chain138-command-center.html', label: 'Command Center', description: 'Open the visual command-center reference.', external: true },
|
||||
{ href: '/topology', label: 'Command Center', description: 'Open the visual topology and architecture map.' },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
@@ -1,60 +1,97 @@
|
||||
import { buildPaginationItems } from '@/utils/pagination'
|
||||
|
||||
interface PaginationControlsProps {
|
||||
page: number
|
||||
pageCount: number
|
||||
onPageChange: (page: number) => void
|
||||
label?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
ariaLabel?: string
|
||||
/** Known total pages for bounded pagination. Omit for cursor-style list pagination. */
|
||||
pageCount?: number
|
||||
/** Used when pageCount is omitted. */
|
||||
hasNextPage?: boolean
|
||||
}
|
||||
|
||||
const buttonClassName =
|
||||
'rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300'
|
||||
|
||||
const activeButtonClassName = 'rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white'
|
||||
|
||||
export default function PaginationControls({
|
||||
page,
|
||||
pageCount,
|
||||
hasNextPage = false,
|
||||
onPageChange,
|
||||
label = 'Rows',
|
||||
className = '',
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
}: PaginationControlsProps) {
|
||||
if (pageCount <= 1) return null
|
||||
const isBounded = typeof pageCount === 'number'
|
||||
const boundedPageCount = pageCount ?? 1
|
||||
|
||||
const pages = Array.from({ length: pageCount }, (_, index) => index + 1)
|
||||
if (isBounded && boundedPageCount <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isBounded && page <= 1 && !hasNextPage) {
|
||||
return null
|
||||
}
|
||||
|
||||
const paginationItems = isBounded ? buildPaginationItems(page, boundedPageCount) : []
|
||||
const canGoPrevious = page > 1
|
||||
const canGoNext = isBounded ? page < boundedPageCount : hasNextPage
|
||||
const navLabel = ariaLabel || `${label} pagination`
|
||||
const statusText = isBounded ? `${label}: page ${page} of ${boundedPageCount}` : `${label}: page ${page}`
|
||||
|
||||
return (
|
||||
<div className={`mt-4 flex flex-wrap items-center justify-between gap-3 ${className}`}>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{label}: page {page} of {pageCount}
|
||||
</div>
|
||||
<nav
|
||||
className={`mt-4 flex flex-wrap items-center justify-between gap-3 ${className}`}
|
||||
aria-label={navLabel}
|
||||
>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">{statusText}</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page <= 1}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
disabled={disabled || !canGoPrevious}
|
||||
className={buttonClassName}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
{pages.map((candidate) => (
|
||||
<button
|
||||
key={candidate}
|
||||
type="button"
|
||||
onClick={() => onPageChange(candidate)}
|
||||
aria-current={candidate === page ? 'page' : undefined}
|
||||
className={
|
||||
candidate === page
|
||||
? 'rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white'
|
||||
: 'rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300'
|
||||
}
|
||||
>
|
||||
{candidate}
|
||||
</button>
|
||||
))}
|
||||
{isBounded ? (
|
||||
<ul className="flex flex-wrap items-center gap-2">
|
||||
{paginationItems.map((item) =>
|
||||
item.type === 'ellipsis' ? (
|
||||
<li key={item.key} className="px-1 text-sm text-gray-500 dark:text-gray-400" aria-hidden="true">
|
||||
…
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.page}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(item.page)}
|
||||
disabled={disabled}
|
||||
aria-current={item.page === page ? 'page' : undefined}
|
||||
className={item.page === page ? activeButtonClassName : buttonClassName}
|
||||
>
|
||||
{item.page}
|
||||
</button>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(Math.min(pageCount, page + 1))}
|
||||
disabled={page >= pageCount}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
onClick={() => onPageChange(isBounded ? Math.min(boundedPageCount, page + 1) : page + 1)}
|
||||
disabled={disabled || !canGoNext}
|
||||
className={buttonClassName}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useCallback, useId } from 'react'
|
||||
|
||||
export interface SectionTab<T extends string> {
|
||||
id: T
|
||||
label: string
|
||||
@@ -9,6 +11,21 @@ interface SectionTabsProps<T extends string> {
|
||||
activeTab: T
|
||||
onChange: (tab: T) => void
|
||||
className?: string
|
||||
idPrefix?: string
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
export function sectionTabPanelProps<T extends string>(
|
||||
idPrefix: string,
|
||||
tabId: T,
|
||||
activeTab: T,
|
||||
) {
|
||||
return {
|
||||
id: `${idPrefix}-panel-${tabId}`,
|
||||
role: 'tabpanel' as const,
|
||||
'aria-labelledby': `${idPrefix}-tab-${tabId}`,
|
||||
hidden: activeTab !== tabId,
|
||||
}
|
||||
}
|
||||
|
||||
export default function SectionTabs<T extends string>({
|
||||
@@ -16,29 +33,87 @@ export default function SectionTabs<T extends string>({
|
||||
activeTab,
|
||||
onChange,
|
||||
className = '',
|
||||
idPrefix,
|
||||
ariaLabel = 'Sections',
|
||||
}: SectionTabsProps<T>) {
|
||||
const generatedId = useId().replace(/:/g, '')
|
||||
const tabListPrefix = idPrefix || generatedId
|
||||
|
||||
const focusTab = useCallback(
|
||||
(tabId: T) => {
|
||||
const element = document.getElementById(`${tabListPrefix}-tab-${tabId}`)
|
||||
element?.focus()
|
||||
},
|
||||
[tabListPrefix],
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const currentIndex = tabs.findIndex((tab) => tab.id === activeTab)
|
||||
if (currentIndex < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let nextIndex = currentIndex
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
nextIndex = (currentIndex + 1) % tabs.length
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length
|
||||
} else if (event.key === 'Home') {
|
||||
nextIndex = 0
|
||||
} else if (event.key === 'End') {
|
||||
nextIndex = tabs.length - 1
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const nextTab = tabs[nextIndex]
|
||||
onChange(nextTab.id)
|
||||
focusTab(nextTab.id)
|
||||
},
|
||||
[activeTab, focusTab, onChange, tabs],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={`sticky top-0 z-20 border-b border-gray-200 bg-white/95 py-3 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 ${className}`}>
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={
|
||||
activeTab === tab.id
|
||||
? 'whitespace-nowrap rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white'
|
||||
: 'whitespace-nowrap rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300'
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
{typeof tab.count === 'number' ? (
|
||||
<span className={activeTab === tab.id ? 'ml-2 text-primary-100' : 'ml-2 text-gray-500 dark:text-gray-400'}>
|
||||
{tab.count.toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
<div
|
||||
className={`sticky top-0 z-20 border-b border-gray-200 bg-white/95 py-3 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 ${className}`}
|
||||
>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
className="flex gap-2 overflow-x-auto"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
id={`${tabListPrefix}-tab-${tab.id}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`${tabListPrefix}-panel-${tab.id}`}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={
|
||||
isActive
|
||||
? 'whitespace-nowrap rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white'
|
||||
: 'whitespace-nowrap rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300'
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
{typeof tab.count === 'number' ? (
|
||||
<span className={isActive ? 'ml-2 text-primary-100' : 'ml-2 text-gray-500 dark:text-gray-400'}>
|
||||
{tab.count.toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
|
||||
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
|
||||
import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
|
||||
import OperationsSurfaceNav from './OperationsSurfaceNav'
|
||||
import LpPositionPanel from '@/components/wallet/LpPositionPanel'
|
||||
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
||||
import {
|
||||
formatCurrency,
|
||||
@@ -252,6 +253,12 @@ export default function LiquidityOperationsPage({
|
||||
href: `/api/v1/report/external-indexer-readiness?chainId=138`,
|
||||
notes: 'One JSON posture for DefiLlama, CoinGecko, CoinMarketCap, and Dexscreener readiness.',
|
||||
},
|
||||
{
|
||||
name: 'Curated pool registry',
|
||||
method: 'GET',
|
||||
href: `${tokenAggregationV1Base}/report/pool-registry?chainId=138`,
|
||||
notes: 'DODO PMM + UniV2 pools with lpTokenType metadata; LP receipt tokens are not bridgeable.',
|
||||
},
|
||||
]
|
||||
|
||||
const copyEndpoint = async (endpoint: EndpointCard) => {
|
||||
@@ -480,6 +487,20 @@ export default function LiquidityOperationsPage({
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<Card title="Curated LP registry (Chain 138)">
|
||||
<LpPositionPanel chainId={138} title="Pool registry & LP scan" compact />
|
||||
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Connect a wallet on the{' '}
|
||||
<Link href="/wallet" className="font-medium text-primary-600 hover:underline dark:text-primary-400">
|
||||
Wallet
|
||||
</Link>{' '}
|
||||
page to scan your LP shares and estimated USD NAV. LP tokens are chain-local — bridge underlying c*/cW*
|
||||
instead.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<Card title="Explorer Access Points">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
|
||||
@@ -991,34 +991,47 @@ export default function Home({
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="Quick links" className="mt-8">
|
||||
<Card title="Institutional quick paths" className="mt-8">
|
||||
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
Jump to the explorer surfaces used most often for discovery, liquidity, wallet setup, and bridge monitoring.
|
||||
Canonical discovery, compliance, and liquidity surfaces for institutional users — mesh tokens, official
|
||||
protocols, curated pools, and public JSON APIs.
|
||||
</p>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Link href="/search" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
|
||||
Search
|
||||
<Link href="/search?q=cWUSDC" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Mesh search</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">c* ↔ cW* across chains</div>
|
||||
</Link>
|
||||
<Link href="/access" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
|
||||
Account access
|
||||
<Link href="/protocols" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Official protocols</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Upstream contracts + guardrails</div>
|
||||
</Link>
|
||||
<Link href="/tokens" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
|
||||
Tokens
|
||||
<Link href="/pools" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Pool registry</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Live DODO PMM + UniV2 TVL</div>
|
||||
</Link>
|
||||
<Link href="/wallet" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
|
||||
Wallet & MetaMask
|
||||
<Link href="/wallet" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Wallet & MetaMask</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">13-chain token import</div>
|
||||
</Link>
|
||||
<Link href="/routes" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
|
||||
Routes
|
||||
<Link href="/liquidity" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Liquidity tools</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">LP policy — bridge underlying, not LP</div>
|
||||
</Link>
|
||||
<Link href="/liquidity" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
|
||||
Liquidity
|
||||
<Link href="/token-aggregation/api/v1/report/official-protocols" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Public JSON APIs</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Protocols, pools, routes</div>
|
||||
</Link>
|
||||
<Link href="/bridge" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
|
||||
Bridge
|
||||
<Link href="/search" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Search</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Address, tx, mesh, pools</div>
|
||||
</Link>
|
||||
<Link href="/analytics" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
|
||||
Analytics
|
||||
<Link href="/docs" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Documentation</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">GRU, APIs, operations</div>
|
||||
</Link>
|
||||
<Link href="/bridge" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Bridge</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Relay posture + CCIP lanes</div>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
256
frontend/src/components/pools/PoolDetailPage.tsx
Normal file
256
frontend/src/components/pools/PoolDetailPage.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import OperationsSurfaceNav from '@/components/explorer/OperationsSurfaceNav'
|
||||
import LpPositionPanel from '@/components/wallet/LpPositionPanel'
|
||||
import {
|
||||
fetchPoolRegistry,
|
||||
type CuratedPoolRegistryEntry,
|
||||
} from '@/services/api/liquidityPositions'
|
||||
import { useUiMode } from '@/components/common/UiModeContext'
|
||||
|
||||
function formatUsd(value: number | undefined): string {
|
||||
if (value == null || !Number.isFinite(value)) return 'Unavailable'
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: value >= 1000 ? 0 : 2,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
function formatReserve(raw: string | undefined, symbol: string): string {
|
||||
if (!raw) return 'Unavailable'
|
||||
const decimals = symbol.toUpperCase().includes('BTC') ? 8 : 6
|
||||
try {
|
||||
const value = BigInt(raw)
|
||||
const scale = 10n ** BigInt(decimals)
|
||||
const whole = value / scale
|
||||
const fraction = value % scale
|
||||
const fractionText = fraction
|
||||
.toString()
|
||||
.padStart(decimals, '0')
|
||||
.slice(0, 4)
|
||||
.replace(/0+$/, '')
|
||||
return fractionText
|
||||
? `${whole.toLocaleString()}.${fractionText} ${symbol}`
|
||||
: `${whole.toLocaleString()} ${symbol}`
|
||||
} catch {
|
||||
return 'Unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
function isStableLikeSymbol(symbol: string): boolean {
|
||||
const normalized = symbol.toUpperCase().replace(/^C/, '')
|
||||
return ['USDT', 'USDC', 'EURC', 'EURT', 'GBPC', 'DAI', 'BUSD', 'TUSD', 'FRAX'].includes(normalized)
|
||||
}
|
||||
|
||||
function stableReserveAsymmetryNote(
|
||||
pool: CuratedPoolRegistryEntry,
|
||||
): { severity: 'warning' | 'info'; message: string; deviationBps: number } | null {
|
||||
if (pool.reserve0Usd == null || pool.reserve1Usd == null) return null
|
||||
if (!isStableLikeSymbol(pool.baseSymbol) || !isStableLikeSymbol(pool.quoteSymbol)) return null
|
||||
const legs = [pool.reserve0Usd, pool.reserve1Usd].filter((v) => v > 0)
|
||||
if (legs.length < 2) return null
|
||||
const min = Math.min(...legs)
|
||||
const max = Math.max(...legs)
|
||||
const deviationBps = Math.round(((max - min) / max) * 10000)
|
||||
if (deviationBps < 150) return null
|
||||
return {
|
||||
severity: deviationBps >= 500 ? 'warning' : 'info',
|
||||
deviationBps,
|
||||
message: `Stable-pair reserves are asymmetric (${deviationBps} bps USD leg gap). PMM pools can hold unequal notionals while remaining tradable — do not assume 50/50 peg depth for trade sizing.`,
|
||||
}
|
||||
}
|
||||
|
||||
interface PoolDetailPageProps {
|
||||
poolAddress: string
|
||||
}
|
||||
|
||||
export default function PoolDetailPage({ poolAddress }: PoolDetailPageProps) {
|
||||
const { mode } = useUiMode()
|
||||
const [pools, setPools] = useState<CuratedPoolRegistryEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void fetchPoolRegistry()
|
||||
.then((report) => {
|
||||
if (!active) return
|
||||
setPools(report?.pools ?? [])
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const pool = useMemo(() => {
|
||||
const lower = poolAddress.toLowerCase()
|
||||
return pools.find(
|
||||
(row) => row.poolAddress.toLowerCase() === lower || row.lpTokenAddress.toLowerCase() === lower,
|
||||
)
|
||||
}, [poolAddress, pools])
|
||||
|
||||
const pairLabel = pool ? `${pool.baseSymbol} / ${pool.quoteSymbol}` : poolAddress
|
||||
const asymmetryNote = pool ? stableReserveAsymmetryNote(pool) : null
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<OperationsSurfaceNav />
|
||||
<PageIntro
|
||||
eyebrow="Curated pool registry"
|
||||
title={loading ? 'Loading pool…' : pool ? pairLabel : 'Pool not in registry'}
|
||||
description={
|
||||
mode === 'guided'
|
||||
? 'Chain-local liquidity pool from the curated DODO PMM + UniV2 registry. LP receipt tokens are not bridgeable — bridge underlying c*/cW* instead.'
|
||||
: 'Curated pool detail · LP is chain-local only.'
|
||||
}
|
||||
actions={[
|
||||
{ href: '/liquidity', label: 'Liquidity tools' },
|
||||
{ href: '/pools', label: 'All pools' },
|
||||
{ href: '/wallet', label: 'Scan LP positions' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading pool registry…</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{!loading && !pool ? (
|
||||
<Card title="Not in curated registry">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
This address is not listed in the curated pool registry. It may still be a valid contract on Chain 138 or
|
||||
another network — open the address page for generic explorer data.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<Link href={`/addresses/${poolAddress}`} className="text-primary-600 hover:underline">
|
||||
Open address page →
|
||||
</Link>
|
||||
<Link href="/search" className="text-primary-600 hover:underline">
|
||||
Back to search →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{pool ? (
|
||||
<div className="space-y-6">
|
||||
{asymmetryNote ? (
|
||||
<Card title="Reserve asymmetry notice">
|
||||
<p
|
||||
className={
|
||||
asymmetryNote.severity === 'warning'
|
||||
? 'text-sm text-amber-800 dark:text-amber-200'
|
||||
: 'text-sm text-gray-700 dark:text-gray-300'
|
||||
}
|
||||
>
|
||||
{asymmetryNote.message}
|
||||
</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card title="Pool summary">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<EntityBadge label={pool.venue.replace('_', ' ')} tone="info" />
|
||||
<EntityBadge label={pool.lpTokenType.replace('_', ' ')} tone="neutral" />
|
||||
<EntityBadge label={`chain ${pool.chainId}`} tone="warning" />
|
||||
{pool.integrationStack ? (
|
||||
<EntityBadge label={pool.integrationStack} tone="success" className="normal-case tracking-normal" />
|
||||
) : null}
|
||||
</div>
|
||||
<dl className="mt-4 grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Pool address</dt>
|
||||
<dd className="mt-1">
|
||||
<Address address={pool.poolAddress} truncate showCopy />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">LP token</dt>
|
||||
<dd className="mt-1">
|
||||
<Address address={pool.lpTokenAddress} truncate showCopy />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Base</dt>
|
||||
<dd className="mt-1">
|
||||
<Link href={`/tokens/${pool.baseAddress}`} className="text-primary-600 hover:underline">
|
||||
{pool.baseSymbol}
|
||||
</Link>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Quote</dt>
|
||||
<dd className="mt-1">
|
||||
<Link href={`/tokens/${pool.quoteAddress}`} className="text-primary-600 hover:underline">
|
||||
{pool.quoteSymbol}
|
||||
</Link>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Est. liquidity (USD)</dt>
|
||||
<dd className="mt-1 font-semibold text-gray-900 dark:text-white">
|
||||
{formatUsd(pool.totalLiquidityUsd)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Base reserve</dt>
|
||||
<dd className="mt-1 text-gray-700 dark:text-gray-300">
|
||||
{formatReserve(pool.reserve0, pool.baseSymbol)}
|
||||
{pool.reserve0Usd != null && pool.reserve0Usd > 0 ? (
|
||||
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
({formatUsd(pool.reserve0Usd)})
|
||||
</span>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Quote reserve</dt>
|
||||
<dd className="mt-1 text-gray-700 dark:text-gray-300">
|
||||
{formatReserve(pool.reserve1, pool.quoteSymbol)}
|
||||
{pool.reserve1Usd != null && pool.reserve1Usd > 0 ? (
|
||||
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
({formatUsd(pool.reserve1Usd)})
|
||||
</span>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Liquidity source</dt>
|
||||
<dd className="mt-1 text-gray-700 dark:text-gray-300">
|
||||
{pool.liquiditySource ?? 'curated registry'}
|
||||
{pool.liquidityAsOf ? (
|
||||
<span className="block text-xs text-gray-500 dark:text-gray-400">
|
||||
As of {new Date(pool.liquidityAsOf).toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Policy</dt>
|
||||
<dd className="mt-1 text-gray-700 dark:text-gray-300">
|
||||
LP shares are chain-local receipts — not cross-chain mesh tokens.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<LpPositionPanel
|
||||
chainId={pool.chainId}
|
||||
title="Your LP in this pool"
|
||||
compact
|
||||
hintAddresses={[pool.poolAddress, pool.lpTokenAddress]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,30 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
|
||||
import { tokensApi } from '@/services/api/tokens'
|
||||
import { selectWalletFeaturedTokens } from '@/utils/featuredTokens'
|
||||
import MultiChainWalletImport from '@/components/wallet/MultiChainWalletImport'
|
||||
import MobileWalletContextBanner from '@/components/wallet/MobileWalletContextBanner'
|
||||
import WalletFundedTokenListing from '@/components/wallet/WalletFundedTokenListing'
|
||||
import { getActiveWalletConnectProvider } from '@/services/wallet/walletConnectClient'
|
||||
import { toWalletAddEthereumChainParams } from '@/utils/walletAddEthereumChain'
|
||||
import {
|
||||
isMobileWalletContext,
|
||||
MOBILE_WALLET_BUTTON_CLASS,
|
||||
resolveWalletEthereumProvider,
|
||||
type EthereumProvider,
|
||||
} from '@/utils/walletProviderEnv'
|
||||
import { buildWatchAssetRpcRequest, runWatchAssetBatch } from '@/utils/walletWatchAsset'
|
||||
import {
|
||||
CHAIN138_PLACEHOLDER_GAS_SYMBOLS,
|
||||
dedupeWalletWatchTokens,
|
||||
isWalletWatchEligibleAddress,
|
||||
} from '@/utils/walletWatchEligible'
|
||||
import {
|
||||
CHAIN138_NATIVE_ETH_LOGO,
|
||||
formatFundedRowBalanceUsd,
|
||||
fundedRowsToWatchCatalogTokens,
|
||||
loadFundedWalletTokenListing,
|
||||
type FundedWalletTokenRow,
|
||||
} from '@/utils/walletFundedTokenListing'
|
||||
|
||||
export type WalletChain = {
|
||||
chainId: string
|
||||
@@ -117,6 +141,13 @@ export type FetchMetadata = {
|
||||
lastModified?: string | null
|
||||
}
|
||||
|
||||
type PendingWatchFlow = {
|
||||
tokens: TokenListToken[]
|
||||
label: string
|
||||
nextIndex: number
|
||||
totalAdded: number
|
||||
}
|
||||
|
||||
interface AddToMetaMaskProps {
|
||||
initialNetworks?: NetworksCatalog | null
|
||||
initialTokenList?: TokenListCatalog | null
|
||||
@@ -126,8 +157,10 @@ interface AddToMetaMaskProps {
|
||||
initialCapabilitiesMeta?: FetchMetadata | null
|
||||
}
|
||||
|
||||
type EthereumProvider = {
|
||||
request: (args: { method: string; params?: unknown }) => Promise<unknown>
|
||||
function chainParamsForWallet(chain: WalletChain) {
|
||||
return toWalletAddEthereumChainParams(chain, {
|
||||
preferSingleRpc: typeof window !== 'undefined' && isMobileWalletContext(),
|
||||
})
|
||||
}
|
||||
|
||||
const FALLBACK_CHAIN_138: WalletChain = {
|
||||
@@ -135,7 +168,7 @@ const FALLBACK_CHAIN_138: WalletChain = {
|
||||
chainIdDecimal: 138,
|
||||
chainName: 'DeFi Oracle Meta Mainnet',
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
rpcUrls: ['https://rpc-http-pub.d-bis.org', 'https://rpc.d-bis.org', 'https://rpc2.d-bis.org'],
|
||||
rpcUrls: ['https://rpc-http-pub.d-bis.org'],
|
||||
blockExplorerUrls: ['https://explorer.d-bis.org', 'https://blockscout.defi-oracle.io'],
|
||||
iconUrls: [
|
||||
'https://explorer.d-bis.org/api/v1/report/logo/chain-138',
|
||||
@@ -353,13 +386,36 @@ export function AddToMetaMask({
|
||||
const [metamaskConfigMeta, setMetamaskConfigMeta] = useState<FetchMetadata | null>(null)
|
||||
const [curatedTokens, setCuratedTokens] = useState<TokenListToken[]>([])
|
||||
const [watchAssetProgress, setWatchAssetProgress] = useState<{ current: number; total: number } | null>(null)
|
||||
const [balanceCheckProgress, setBalanceCheckProgress] = useState<{ current: number; total: number } | null>(null)
|
||||
const [fundedTokenRows, setFundedTokenRows] = useState<FundedWalletTokenRow[]>([])
|
||||
const [fundedListingWallet, setFundedListingWallet] = useState<string | null>(null)
|
||||
const [fundedListingLoading, setFundedListingLoading] = useState(false)
|
||||
const [fundedListingError, setFundedListingError] = useState<string | null>(null)
|
||||
const [pendingWatchFlow, setPendingWatchFlow] = useState<PendingWatchFlow | null>(null)
|
||||
const [providerTick, setProviderTick] = useState(0)
|
||||
|
||||
const ethereum = typeof window !== 'undefined'
|
||||
? (window as unknown as { ethereum?: EthereumProvider }).ethereum
|
||||
: undefined
|
||||
const mobileWalletContext = typeof window !== 'undefined' && isMobileWalletContext()
|
||||
|
||||
const resolveEthereum = (): EthereumProvider | undefined => {
|
||||
void providerTick
|
||||
return resolveWalletEthereumProvider(getActiveWalletConnectProvider())
|
||||
}
|
||||
|
||||
const hasWalletProvider = typeof window !== 'undefined' && Boolean(resolveEthereum())
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return undefined
|
||||
const refresh = () => setProviderTick((value) => value + 1)
|
||||
window.addEventListener('ethereum#initialized', refresh)
|
||||
window.addEventListener('focus', refresh)
|
||||
return () => {
|
||||
window.removeEventListener('ethereum#initialized', refresh)
|
||||
window.removeEventListener('focus', refresh)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const apiBase = getApiBase().replace(/\/$/, '')
|
||||
const tokenListUrl = `${apiBase}/api/v1/report/token-list?chainId=138`
|
||||
const tokenListUrl = `${apiBase}/api/v1/report/token-list?chainId=138&wallet=1`
|
||||
const networksUrl = `${apiBase}/api/config/networks`
|
||||
const metamaskConfigUrl = `${apiBase}/api/v1/config/metamask?chainId=138`
|
||||
const capabilitiesUrl = `${apiBase}/api/config/capabilities`
|
||||
@@ -476,7 +532,10 @@ export function AddToMetaMask({
|
||||
}, [])
|
||||
|
||||
const catalogTokens = useMemo(
|
||||
() => (Array.isArray(tokenList?.tokens) ? tokenList.tokens.filter(isTokenListToken) : []),
|
||||
() =>
|
||||
(Array.isArray(tokenList?.tokens) ? tokenList.tokens.filter(isTokenListToken) : []).filter((token) =>
|
||||
isWalletWatchEligibleAddress(token.address),
|
||||
),
|
||||
[tokenList],
|
||||
)
|
||||
|
||||
@@ -502,27 +561,31 @@ export function AddToMetaMask({
|
||||
)
|
||||
|
||||
const watchAssetTokens = useMemo(() => {
|
||||
const endpointTokens = (metamaskConfig?.watchAssets || [])
|
||||
.filter(isWatchAssetEntry)
|
||||
.map(watchAssetToToken)
|
||||
const endpointTokens = dedupeWalletWatchTokens(
|
||||
(metamaskConfig?.watchAssets || [])
|
||||
.filter(isWatchAssetEntry)
|
||||
.map(watchAssetToToken)
|
||||
.filter((token) => isWalletWatchEligibleAddress(token.address)),
|
||||
)
|
||||
|
||||
if (endpointTokens.length > 0) return endpointTokens
|
||||
return catalogTokens.filter((token) => token.chainId === 138)
|
||||
return dedupeWalletWatchTokens(catalogTokens.filter((token) => token.chainId === 138))
|
||||
}, [catalogTokens, metamaskConfig])
|
||||
|
||||
const addChain = async (chain: WalletChain) => {
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
|
||||
const ethereum = resolveEthereum()
|
||||
if (!ethereum) {
|
||||
setError('MetaMask or another Web3 wallet is not installed.')
|
||||
setError('No wallet provider found. Open this page in MetaMask mobile, install a browser wallet, or connect WalletConnect above.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ethereum.request({
|
||||
method: 'wallet_addEthereumChain',
|
||||
params: [chain],
|
||||
params: [chainParamsForWallet(chain)],
|
||||
})
|
||||
setStatus(`Added ${chain.chainName}. You can switch to it in your wallet.`)
|
||||
} catch (e) {
|
||||
@@ -536,8 +599,9 @@ export function AddToMetaMask({
|
||||
}
|
||||
|
||||
const switchOrAddChain = async (chain: WalletChain) => {
|
||||
const ethereum = resolveEthereum()
|
||||
if (!ethereum) {
|
||||
setError('MetaMask or another Web3 wallet is not installed.')
|
||||
setError('No wallet provider found. Open this page in MetaMask mobile, install a browser wallet, or connect WalletConnect above.')
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -558,7 +622,7 @@ export function AddToMetaMask({
|
||||
try {
|
||||
await ethereum.request({
|
||||
method: 'wallet_addEthereumChain',
|
||||
params: [chain],
|
||||
params: [chainParamsForWallet(chain)],
|
||||
})
|
||||
return true
|
||||
} catch (e) {
|
||||
@@ -572,8 +636,9 @@ export function AddToMetaMask({
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
|
||||
const ethereum = resolveEthereum()
|
||||
if (!ethereum) {
|
||||
setError('MetaMask or another Web3 wallet is not installed.')
|
||||
setError('No wallet provider found. Snaps require MetaMask in a supported browser.')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -602,6 +667,13 @@ export function AddToMetaMask({
|
||||
}
|
||||
}
|
||||
|
||||
const networkForToken = (token: TokenListToken): WalletChain | null => {
|
||||
if (token.chainId === 138) return chains.chain138
|
||||
if (token.chainId === 1) return chains.ethereum
|
||||
if (token.chainId === 651940) return chains.allMainnet
|
||||
return null
|
||||
}
|
||||
|
||||
const watchToken = async (token: TokenListToken) => {
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
@@ -611,32 +683,118 @@ export function AddToMetaMask({
|
||||
return
|
||||
}
|
||||
|
||||
if (!ethereum) {
|
||||
setError('MetaMask or another Web3 wallet is not installed.')
|
||||
if (!isWalletWatchEligibleAddress(token.address)) {
|
||||
setError(
|
||||
`${token.symbol} uses a roadmap placeholder address on Chain 138 (not a live ERC-20). Remove it from MetaMask if it was added earlier, then use Add all Chain 138 tokens for live contracts such as cUSDT, cUSDC, and cBTC.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const added = await ethereum.request({
|
||||
method: 'wallet_watchAsset',
|
||||
params: {
|
||||
type: 'ERC20',
|
||||
options: {
|
||||
address: token.address,
|
||||
symbol: token.symbol,
|
||||
decimals: token.decimals,
|
||||
image: token.logoURI,
|
||||
},
|
||||
},
|
||||
})
|
||||
const ethereum = resolveEthereum()
|
||||
if (!ethereum) {
|
||||
setError('No wallet provider found. Open this page in MetaMask mobile, install a browser wallet, or connect WalletConnect above.')
|
||||
return
|
||||
}
|
||||
|
||||
setStatus(added ? `Added ${token.symbol} to your wallet.` : `${token.symbol} request was dismissed.`)
|
||||
const network = networkForToken(token)
|
||||
if (network) {
|
||||
const switched = await switchOrAddChain(network)
|
||||
if (!switched) return
|
||||
}
|
||||
|
||||
try {
|
||||
const added = await ethereum.request(buildWatchAssetRpcRequest(token, mobileWalletContext))
|
||||
|
||||
setStatus(
|
||||
added
|
||||
? `Added ${token.symbol} on ${network?.chainName || `chain ${token.chainId}`}. Switch to that network in MetaMask to see balances.`
|
||||
: `${token.symbol} request was dismissed.`,
|
||||
)
|
||||
} catch (e) {
|
||||
const err = e as { message?: string }
|
||||
setError(err.message || `Failed to add ${token.symbol}.`)
|
||||
}
|
||||
}
|
||||
|
||||
const watchAssetCatalogTokens = useMemo(
|
||||
() =>
|
||||
watchAssetTokens.map((token) => ({
|
||||
chainId: token.chainId,
|
||||
address: token.address,
|
||||
symbol: token.symbol,
|
||||
name: token.name,
|
||||
decimals: token.decimals,
|
||||
logoURI: token.logoURI,
|
||||
})),
|
||||
[watchAssetTokens],
|
||||
)
|
||||
|
||||
const requestConnectedWalletAddress = async (): Promise<string | null> => {
|
||||
const ethereum = resolveEthereum()
|
||||
if (!ethereum) {
|
||||
setError('No wallet provider found. Open this page in MetaMask mobile, install a browser wallet, or connect WalletConnect above.')
|
||||
return null
|
||||
}
|
||||
|
||||
const switched = await switchOrAddChain(chains.chain138)
|
||||
if (!switched) return null
|
||||
|
||||
try {
|
||||
const accounts = (await ethereum.request({ method: 'eth_requestAccounts' })) as string[]
|
||||
return accounts[0] || null
|
||||
} catch (e) {
|
||||
const err = e as { message?: string }
|
||||
setError(err.message || 'Could not connect a wallet account.')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const refreshFundedTokenListing = async () => {
|
||||
setFundedListingError(null)
|
||||
setFundedListingLoading(true)
|
||||
setBalanceCheckProgress(null)
|
||||
|
||||
try {
|
||||
const walletAddress = await requestConnectedWalletAddress()
|
||||
if (!walletAddress) {
|
||||
setFundedTokenRows([])
|
||||
setFundedListingWallet(null)
|
||||
return
|
||||
}
|
||||
|
||||
const ethereum = resolveEthereum()
|
||||
if (!ethereum) return
|
||||
|
||||
const rows = await loadFundedWalletTokenListing(
|
||||
ethereum,
|
||||
walletAddress,
|
||||
watchAssetCatalogTokens,
|
||||
{
|
||||
nativeLogoUri: CHAIN138_NATIVE_ETH_LOGO,
|
||||
onProgress: (current, total) => setBalanceCheckProgress({ current, total }),
|
||||
},
|
||||
)
|
||||
|
||||
setFundedTokenRows(rows)
|
||||
setFundedListingWallet(walletAddress)
|
||||
setBalanceCheckProgress(null)
|
||||
|
||||
const erc20Count = rows.filter((row) => row.kind === 'erc20').length
|
||||
setStatus(
|
||||
rows.length > 0
|
||||
? `Loaded ${rows.length} funded holding(s) for ${walletAddress.slice(0, 6)}…${walletAddress.slice(-4)} (${erc20Count} ERC-20 + native ETH when present).`
|
||||
: `No funded catalog tokens for ${walletAddress.slice(0, 6)}…${walletAddress.slice(-4)}.`,
|
||||
)
|
||||
} catch (e) {
|
||||
const err = e as { message?: string }
|
||||
setFundedListingError(err.message || 'Failed to load funded token listing.')
|
||||
setFundedTokenRows([])
|
||||
setFundedListingWallet(null)
|
||||
} finally {
|
||||
setFundedListingLoading(false)
|
||||
setBalanceCheckProgress(null)
|
||||
}
|
||||
}
|
||||
const refreshMainnetCwusdc = async () => {
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
@@ -647,51 +805,140 @@ export function AddToMetaMask({
|
||||
await watchToken(MAINNET_CWUSDC_TOKEN)
|
||||
}
|
||||
|
||||
const watchTokensSequentially = async (tokens: TokenListToken[], label: string) => {
|
||||
const watchTokensSequentially = async (
|
||||
tokens: TokenListToken[],
|
||||
label: string,
|
||||
startIndex = 0,
|
||||
priorAdded = 0,
|
||||
) => {
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
if (startIndex === 0) {
|
||||
setStatus(null)
|
||||
setPendingWatchFlow(null)
|
||||
}
|
||||
setWatchAssetProgress(null)
|
||||
setBalanceCheckProgress(null)
|
||||
|
||||
const ethereum = resolveEthereum()
|
||||
if (!ethereum) {
|
||||
setError('MetaMask or another Web3 wallet is not installed.')
|
||||
setError('No wallet provider found. Open this page in MetaMask mobile, install a browser wallet, or connect WalletConnect above.')
|
||||
return
|
||||
}
|
||||
|
||||
const validTokens = tokens.filter(isTokenListToken)
|
||||
const validTokens = dedupeWalletWatchTokens(
|
||||
tokens.filter(isTokenListToken).filter((token) => isWalletWatchEligibleAddress(token.address)),
|
||||
)
|
||||
if (validTokens.length === 0) {
|
||||
setError('No live Chain 138 token metadata is available for wallet_watchAsset right now.')
|
||||
return
|
||||
}
|
||||
|
||||
const switched = await switchOrAddChain(chains.chain138)
|
||||
if (!switched) return
|
||||
|
||||
const batch = await runWatchAssetBatch(ethereum, validTokens, startIndex, {
|
||||
mobile: mobileWalletContext,
|
||||
onProgress: (current, total) => setWatchAssetProgress({ current, total }),
|
||||
})
|
||||
|
||||
const totalAdded = priorAdded + batch.addedCount
|
||||
setWatchAssetProgress(null)
|
||||
|
||||
if (batch.stoppedEarly) {
|
||||
setError(batch.errorMessage || 'Token import stopped.')
|
||||
setPendingWatchFlow(null)
|
||||
setStatus(`${totalAdded} of ${validTokens.length} ${label} token requests were accepted before the flow stopped.`)
|
||||
return
|
||||
}
|
||||
|
||||
if (batch.nextIndex < validTokens.length) {
|
||||
setPendingWatchFlow({
|
||||
tokens: validTokens,
|
||||
label,
|
||||
nextIndex: batch.nextIndex,
|
||||
totalAdded,
|
||||
})
|
||||
setStatus(
|
||||
`${totalAdded} of ${validTokens.length} ${label} tokens added. Tap Continue for the next ${Math.min(validTokens.length - batch.nextIndex, 2)} prompt(s). On mobile, approve each wallet popup before it closes.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setPendingWatchFlow(null)
|
||||
setStatus(
|
||||
`${totalAdded} of ${validTokens.length} ${label} token requests were accepted. Native ETH appears automatically on DeFi Oracle Meta Mainnet (not via Add Token). Stay on Chain 138 with RPC https://rpc-http-pub.d-bis.org to see balances.`,
|
||||
)
|
||||
}
|
||||
|
||||
const continuePendingWatchFlow = async () => {
|
||||
if (!pendingWatchFlow) return
|
||||
const { tokens, label, nextIndex, totalAdded } = pendingWatchFlow
|
||||
await watchTokensSequentially(tokens, label, nextIndex, totalAdded)
|
||||
}
|
||||
|
||||
const watchTokensWithBalanceOnly = async () => {
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
setWatchAssetProgress(null)
|
||||
setBalanceCheckProgress(null)
|
||||
setFundedListingError(null)
|
||||
|
||||
const ethereum = resolveEthereum()
|
||||
if (!ethereum) {
|
||||
setError('No wallet provider found. Open this page in MetaMask mobile, install a browser wallet, or connect WalletConnect above.')
|
||||
return
|
||||
}
|
||||
|
||||
const candidates = watchAssetTokens.filter(isTokenListToken)
|
||||
if (candidates.length === 0) {
|
||||
setError('No complete token metadata is available for wallet_watchAsset right now.')
|
||||
return
|
||||
}
|
||||
|
||||
let addedCount = 0
|
||||
for (let index = 0; index < validTokens.length; index += 1) {
|
||||
const token = validTokens[index]
|
||||
setWatchAssetProgress({ current: index + 1, total: validTokens.length })
|
||||
try {
|
||||
const added = await ethereum.request({
|
||||
method: 'wallet_watchAsset',
|
||||
params: {
|
||||
type: 'ERC20',
|
||||
options: {
|
||||
address: token.address,
|
||||
symbol: token.symbol,
|
||||
decimals: token.decimals,
|
||||
image: token.logoURI,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (added) addedCount += 1
|
||||
} catch (e) {
|
||||
const err = e as { message?: string }
|
||||
setError(err.message || `Stopped while adding ${token.symbol}.`)
|
||||
setStatus(`${addedCount} of ${validTokens.length} ${label} token requests were accepted before the flow stopped.`)
|
||||
setWatchAssetProgress(null)
|
||||
setFundedListingLoading(true)
|
||||
try {
|
||||
const walletAddress = await requestConnectedWalletAddress()
|
||||
if (!walletAddress) return
|
||||
|
||||
setStatus(`Checking ERC-20 balances and market prices for ${candidates.length} catalog tokens on Chain 138…`)
|
||||
|
||||
const rows = await loadFundedWalletTokenListing(
|
||||
ethereum,
|
||||
walletAddress,
|
||||
watchAssetCatalogTokens,
|
||||
{
|
||||
nativeLogoUri: CHAIN138_NATIVE_ETH_LOGO,
|
||||
onProgress: (current, total) => setBalanceCheckProgress({ current, total }),
|
||||
},
|
||||
)
|
||||
|
||||
setFundedTokenRows(rows)
|
||||
setFundedListingWallet(walletAddress)
|
||||
setBalanceCheckProgress(null)
|
||||
|
||||
const fundedTokens = fundedRowsToWatchCatalogTokens(rows)
|
||||
if (fundedTokens.length === 0) {
|
||||
const nativeRow = rows.find((row) => row.kind === 'native')
|
||||
setStatus(
|
||||
nativeRow
|
||||
? `Native ETH: ${nativeRow.balanceLabel} (${formatFundedRowBalanceUsd(nativeRow)}). No ERC-20 balances among catalog tokens — nothing to add via EIP-747.`
|
||||
: `No ERC-20 balances found among ${candidates.length} catalog tokens for ${walletAddress.slice(0, 6)}…${walletAddress.slice(-4)}.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setWatchAssetProgress(null)
|
||||
setStatus(`${addedCount} of ${validTokens.length} ${label} token requests were accepted by the wallet.`)
|
||||
const symbols = fundedTokens.map((token) => token.symbol).join(', ')
|
||||
setStatus(
|
||||
`Found ${fundedTokens.length} ERC-20 token(s) with balance (${symbols}). Native ETH is listed above when present. Starting wallet prompts…`,
|
||||
)
|
||||
await watchTokensSequentially(fundedTokens as TokenListToken[], 'funded Chain 138')
|
||||
} catch (e) {
|
||||
const err = e as { message?: string }
|
||||
setError(err.message || 'Failed to read token balances from Chain 138.')
|
||||
setBalanceCheckProgress(null)
|
||||
} finally {
|
||||
setFundedListingLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyText = async (value: string, label: string) => {
|
||||
@@ -727,25 +974,38 @@ export function AddToMetaMask({
|
||||
metadata directly to the wallet.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<MobileWalletContextBanner hasProvider={hasWalletProvider} />
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void switchOrAddChain(chains.chain138).then((ok) => {
|
||||
if (ok) setStatus('Switched to DeFi Oracle Meta Mainnet. Native ETH and imported tokens show on this network.')
|
||||
})
|
||||
}
|
||||
className={`rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Switch to Chain 138
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addChain(chains.chain138)}
|
||||
className="rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
|
||||
className={`rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add Chain 138
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addChain(chains.ethereum)}
|
||||
className="rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700"
|
||||
className={`rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add Ethereum Mainnet
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addChain(chains.allMainnet)}
|
||||
className="rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700"
|
||||
className={`rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add ALL Mainnet
|
||||
</button>
|
||||
@@ -893,29 +1153,84 @@ export function AddToMetaMask({
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
These tokens come from the explorer MetaMask payload and use wallet_watchAsset so the wallet gets the same
|
||||
symbol, decimals, image, and optional token metadata that the explorer publishes. MetaMask requires a user
|
||||
approval for each token, so the bulk actions below run as a guided sequence of wallet prompts.
|
||||
approval for each token, so the bulk actions below run as a guided sequence of wallet prompts. Bulk add
|
||||
switches to DeFi Oracle Meta Mainnet first — tokens imported while on another network will not show
|
||||
balances on Chain 138. Native ETH is not added via this flow; it appears when you are on Chain 138 with a
|
||||
working RPC. Use <span className="font-medium">Add tokens with balance only</span> to skip zero-balance and
|
||||
undeployed catalog entries.
|
||||
{mobileWalletContext
|
||||
? ' On mobile, imports run two wallet prompts per tap — use Continue until finished.'
|
||||
: null}
|
||||
</p>
|
||||
<p className="mt-2 rounded-lg border border-amber-200 bg-amber-50/80 p-3 text-sm leading-6 text-amber-950 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-100">
|
||||
MetaMask keeps <span className="font-medium">one row per token symbol</span> on a network. If a balance
|
||||
disappears after import, remove duplicate custom tokens (especially second <code className="text-xs">cUSDC</code>{' '}
|
||||
/ <code className="text-xs">cUSDT</code> rows or old gas placeholders like{' '}
|
||||
{CHAIN138_PLACEHOLDER_GAS_SYMBOLS.slice(0, 3).join(', ')}), stay on{' '}
|
||||
<span className="font-medium">DeFi Oracle Meta Mainnet</span>, then tap{' '}
|
||||
<span className="font-medium">Add tokens with balance only</span> or{' '}
|
||||
<span className="font-medium">Add featured tokens</span>.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{pendingWatchFlow ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={watchAssetProgress !== null || balanceCheckProgress !== null || fundedListingLoading}
|
||||
onClick={() => void continuePendingWatchFlow()}
|
||||
className={`rounded bg-amber-600 px-3 py-2 text-sm font-medium text-white hover:bg-amber-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Continue ({pendingWatchFlow.totalAdded} added, {pendingWatchFlow.tokens.length - pendingWatchFlow.nextIndex} left)
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={watchAssetProgress !== null || balanceCheckProgress !== null || fundedListingLoading}
|
||||
onClick={() => void watchTokensWithBalanceOnly()}
|
||||
className={`rounded bg-emerald-600 px-3 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add tokens with balance only
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={watchAssetProgress !== null || balanceCheckProgress !== null || fundedListingLoading || pendingWatchFlow !== null}
|
||||
onClick={() => void watchTokensSequentially(featuredTokens, 'featured Chain 138')}
|
||||
className="rounded bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700"
|
||||
className={`rounded bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add featured tokens
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={watchAssetProgress !== null || balanceCheckProgress !== null || fundedListingLoading || pendingWatchFlow !== null}
|
||||
onClick={() => void watchTokensSequentially(watchAssetTokens, 'Chain 138')}
|
||||
className="rounded bg-gray-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-gray-700"
|
||||
className={`rounded bg-gray-600 px-3 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add all Chain 138 tokens
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={watchAssetProgress !== null || balanceCheckProgress !== null || fundedListingLoading}
|
||||
onClick={() => void refreshFundedTokenListing()}
|
||||
className={`rounded border border-emerald-600 px-3 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50 disabled:opacity-50 dark:text-emerald-300 dark:hover:bg-emerald-950/30 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Refresh funded holdings
|
||||
</button>
|
||||
{balanceCheckProgress ? (
|
||||
<span className="self-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Balance check {balanceCheckProgress.current} of {balanceCheckProgress.total}
|
||||
</span>
|
||||
) : null}
|
||||
{watchAssetProgress ? (
|
||||
<span className="self-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Prompt {watchAssetProgress.current} of {watchAssetProgress.total}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<WalletFundedTokenListing
|
||||
rows={fundedTokenRows}
|
||||
walletAddress={fundedListingWallet}
|
||||
loading={fundedListingLoading}
|
||||
error={fundedListingError}
|
||||
/>
|
||||
<div className="mt-4 space-y-3">
|
||||
{featuredTokens.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Featured token metadata is not available right now.</p>
|
||||
@@ -940,7 +1255,7 @@ export function AddToMetaMask({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => watchToken(token)}
|
||||
className="rounded bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700"
|
||||
className={`rounded bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add {token.symbol}
|
||||
</button>
|
||||
@@ -982,6 +1297,8 @@ export function AddToMetaMask({
|
||||
|
||||
{status ? <p className="text-sm text-green-600 dark:text-green-400">{status}</p> : null}
|
||||
{error ? <p className="text-sm text-red-600 dark:text-red-400">{error}</p> : null}
|
||||
|
||||
<MultiChainWalletImport />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
169
frontend/src/components/wallet/LpPositionPanel.tsx
Normal file
169
frontend/src/components/wallet/LpPositionPanel.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
fetchLpPositions,
|
||||
fetchPoolRegistry,
|
||||
type LpPositionRow,
|
||||
type PoolRegistryResponse,
|
||||
} from '@/services/api/liquidityPositions'
|
||||
import { chainLabel } from '@/utils/walletChainCatalog'
|
||||
|
||||
function formatUsd(value: number | null | undefined): string {
|
||||
if (value == null || Number.isNaN(value)) return '—'
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: value >= 1000 ? 0 : 2,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
function formatShare(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return '0%'
|
||||
return `${(value * 100).toFixed(value < 0.01 ? 4 : 2)}%`
|
||||
}
|
||||
|
||||
interface LpPositionPanelProps {
|
||||
chainId?: number
|
||||
address?: string | null
|
||||
hintAddresses?: string[]
|
||||
title?: string
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export default function LpPositionPanel({
|
||||
chainId = 138,
|
||||
address,
|
||||
hintAddresses = [],
|
||||
title = 'LP positions',
|
||||
compact = false,
|
||||
}: LpPositionPanelProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [positions, setPositions] = useState<LpPositionRow[]>([])
|
||||
const [totalUsd, setTotalUsd] = useState<number | null>(null)
|
||||
const [registry, setRegistry] = useState<PoolRegistryResponse | null>(null)
|
||||
const [notes, setNotes] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchPoolRegistry(chainId).then(setRegistry).catch(() => setRegistry(null))
|
||||
}, [chainId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!address) {
|
||||
setPositions([])
|
||||
setTotalUsd(null)
|
||||
setNotes([])
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
void fetchLpPositions({ chainId, address, hintAddresses })
|
||||
.then((report) => {
|
||||
if (!active) return
|
||||
if (!report) {
|
||||
setError('LP position scan is unavailable right now.')
|
||||
setPositions([])
|
||||
setTotalUsd(null)
|
||||
return
|
||||
}
|
||||
setPositions(report.positions)
|
||||
setTotalUsd(report.totalEstimatedUsd)
|
||||
setNotes(report.notes)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return
|
||||
setError('LP position scan failed.')
|
||||
setPositions([])
|
||||
setTotalUsd(null)
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [address, chainId, hintAddresses.join(',')])
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 p-4 dark:border-gray-800 dark:bg-gray-900/40">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{title}</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Curated DODO PMM + UniV2 pools on {chainLabel(chainId)}
|
||||
{registry ? ` · ${registry.count} pools in registry` : ''}
|
||||
{address ? '' : ' · connect a wallet to scan LP shares'}
|
||||
</div>
|
||||
</div>
|
||||
{totalUsd != null ? (
|
||||
<div className="text-right">
|
||||
<div className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Estimated LP NAV</div>
|
||||
<div className="text-lg font-semibold text-gray-900 dark:text-white">{formatUsd(totalUsd)}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-4 text-sm text-gray-600 dark:text-gray-400">Scanning on-chain LP balances…</div>
|
||||
) : null}
|
||||
{error ? <div className="mt-4 text-sm text-red-600 dark:text-red-400">{error}</div> : null}
|
||||
|
||||
{!loading && !error && address && positions.length === 0 ? (
|
||||
<div className="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
No active LP shares detected in the curated pool registry for this address.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{positions.length > 0 ? (
|
||||
<div className={`mt-4 space-y-3 ${compact ? '' : ''}`}>
|
||||
{positions.map((position) => (
|
||||
<div
|
||||
key={position.poolAddress}
|
||||
className="rounded-xl border border-gray-200 bg-white/80 px-4 py-3 dark:border-gray-700 dark:bg-black/10"
|
||||
>
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">
|
||||
{position.pairLabel}{' '}
|
||||
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
({position.venue.replace('_', ' ')})
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Pool {position.poolAddress.slice(0, 10)}…{position.poolAddress.slice(-8)} · share{' '}
|
||||
{formatShare(position.shareOfPool)} · balance {position.shareBalanceUnits}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-col items-end gap-1">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{formatUsd(position.estimatedUsd)}
|
||||
</div>
|
||||
<Link href={`/pools/${position.poolAddress}`} className="text-xs text-primary-600 hover:underline">
|
||||
Pool detail →
|
||||
</Link>
|
||||
<Link href={`/liquidity`} className="text-xs text-primary-600 hover:underline">
|
||||
Liquidity tools →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{notes.length > 0 ? (
|
||||
<div className="mt-4 space-y-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{notes.map((note) => (
|
||||
<p key={note}>{note}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
frontend/src/components/wallet/MobileWalletContextBanner.tsx
Normal file
49
frontend/src/components/wallet/MobileWalletContextBanner.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
buildCoinbaseWalletDappUrl,
|
||||
buildMetaMaskMobileDappUrl,
|
||||
isMobileBrowser,
|
||||
isWalletInAppBrowser,
|
||||
MOBILE_WALLET_BUTTON_CLASS,
|
||||
} from '@/utils/walletProviderEnv'
|
||||
|
||||
type MobileWalletContextBannerProps = {
|
||||
hasProvider: boolean
|
||||
}
|
||||
|
||||
export default function MobileWalletContextBanner({ hasProvider }: MobileWalletContextBannerProps) {
|
||||
if (typeof window === 'undefined') return null
|
||||
if (!isMobileBrowser() || isWalletInAppBrowser()) return null
|
||||
|
||||
const pageUrl = window.location.href
|
||||
const metamaskUrl = buildMetaMaskMobileDappUrl(pageUrl)
|
||||
const coinbaseUrl = buildCoinbaseWalletDappUrl(pageUrl)
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-sky-200 bg-sky-50/80 p-4 dark:border-sky-900/50 dark:bg-sky-950/30">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Mobile wallet browser</div>
|
||||
<p className="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{hasProvider
|
||||
? 'You are in a mobile browser with a wallet provider. Add-chain and add-token buttons work best one or two tokens at a time — use Continue when prompted. If a prompt does not appear, unlock your wallet and stay on this tab.'
|
||||
: 'Mobile Safari/Chrome do not expose a wallet extension. Open this page inside MetaMask or Coinbase Wallet, or connect with WalletConnect above, then use the add-chain and add-token buttons.'}
|
||||
</p>
|
||||
{!hasProvider ? (
|
||||
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
<a
|
||||
href={metamaskUrl}
|
||||
className={`inline-flex items-center justify-center rounded-lg bg-orange-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-orange-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Open in MetaMask
|
||||
</a>
|
||||
<a
|
||||
href={coinbaseUrl}
|
||||
className={`inline-flex items-center justify-center rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Open in Coinbase Wallet
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
336
frontend/src/components/wallet/MultiChainWalletImport.tsx
Normal file
336
frontend/src/components/wallet/MultiChainWalletImport.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
|
||||
import type { TokenListToken, WalletChain } from '@/components/wallet/AddToMetaMask'
|
||||
import { getActiveWalletConnectProvider } from '@/services/wallet/walletConnectClient'
|
||||
import {
|
||||
WALLET_FEATURED_SYMBOLS_BY_CHAIN,
|
||||
WALLET_IMPORT_CHAIN_IDS,
|
||||
chainLabel,
|
||||
} from '@/utils/walletChainCatalog'
|
||||
import { toWalletAddEthereumChainParams } from '@/utils/walletAddEthereumChain'
|
||||
import {
|
||||
isMobileWalletContext,
|
||||
MOBILE_WALLET_BUTTON_CLASS,
|
||||
resolveWalletEthereumProvider,
|
||||
} from '@/utils/walletProviderEnv'
|
||||
import { runWatchAssetBatch } from '@/utils/walletWatchAsset'
|
||||
import { dedupeWalletWatchTokens } from '@/utils/walletWatchEligible'
|
||||
|
||||
type WatchAssetEntry = {
|
||||
type: 'ERC20'
|
||||
options: {
|
||||
address: string
|
||||
symbol: string
|
||||
decimals: number
|
||||
image?: string
|
||||
}
|
||||
}
|
||||
|
||||
type MetaMaskChainPayload = {
|
||||
chainId?: number
|
||||
addEthereumChain?: WalletChain
|
||||
watchAssets?: WatchAssetEntry[]
|
||||
}
|
||||
|
||||
type ChainImportState = {
|
||||
chainId: number
|
||||
network: WalletChain | null
|
||||
tokens: TokenListToken[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
type PendingMultiChainFlow = {
|
||||
chainId: number
|
||||
tokens: TokenListToken[]
|
||||
label: string
|
||||
nextIndex: number
|
||||
totalAdded: number
|
||||
}
|
||||
|
||||
function isTokenListToken(value: unknown): value is TokenListToken {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const candidate = value as Partial<TokenListToken>
|
||||
return (
|
||||
typeof candidate.chainId === 'number' &&
|
||||
typeof candidate.address === 'string' &&
|
||||
typeof candidate.symbol === 'string' &&
|
||||
typeof candidate.decimals === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function watchAssetToToken(chainId: number, entry: WatchAssetEntry): TokenListToken {
|
||||
return {
|
||||
chainId,
|
||||
address: entry.options.address,
|
||||
symbol: entry.options.symbol,
|
||||
name: entry.options.symbol,
|
||||
decimals: entry.options.decimals,
|
||||
logoURI: entry.options.image,
|
||||
}
|
||||
}
|
||||
|
||||
function getApiBase() {
|
||||
return resolveExplorerApiBase({
|
||||
browserOrigin: '',
|
||||
serverFallback: 'https://explorer.d-bis.org',
|
||||
}).replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function chainParamsForWallet(chain: WalletChain) {
|
||||
return toWalletAddEthereumChainParams(chain, {
|
||||
preferSingleRpc: typeof window !== 'undefined' && isMobileWalletContext(),
|
||||
})
|
||||
}
|
||||
|
||||
export default function MultiChainWalletImport() {
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [progress, setProgress] = useState<{ current: number; total: number; chainId: number } | null>(null)
|
||||
const [chains, setChains] = useState<ChainImportState[]>([])
|
||||
const [pendingFlow, setPendingFlow] = useState<PendingMultiChainFlow | null>(null)
|
||||
const mobileWalletContext = typeof window !== 'undefined' && isMobileWalletContext()
|
||||
|
||||
const resolveEthereum = () => resolveWalletEthereumProvider(getActiveWalletConnectProvider())
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const apiBase = getApiBase()
|
||||
|
||||
async function loadChain(chainId: number): Promise<ChainImportState> {
|
||||
try {
|
||||
const [networkRes, metamaskRes] = await Promise.all([
|
||||
fetch(`${apiBase}/api/config/networks`, { cache: 'no-store' }),
|
||||
fetch(`${apiBase}/api/v1/config/metamask?chainId=${chainId}`, { cache: 'no-store' }),
|
||||
])
|
||||
const networksJson = networkRes.ok ? await networkRes.json() : null
|
||||
const metamaskJson = metamaskRes.ok ? await metamaskRes.json() : null
|
||||
const networkList = Array.isArray(networksJson?.chains) ? networksJson.chains : []
|
||||
const network =
|
||||
(metamaskJson as MetaMaskChainPayload | null)?.addEthereumChain ||
|
||||
networkList.find((row: WalletChain) => row.chainIdDecimal === chainId) ||
|
||||
null
|
||||
const watchAssets = Array.isArray((metamaskJson as MetaMaskChainPayload | null)?.watchAssets)
|
||||
? ((metamaskJson as MetaMaskChainPayload).watchAssets ?? [])
|
||||
: []
|
||||
let tokens = watchAssets.map((entry) => watchAssetToToken(chainId, entry))
|
||||
if (tokens.length === 0) {
|
||||
const listRes = await fetch(`${apiBase}/api/v1/report/token-list?chainId=${chainId}`, { cache: 'no-store' })
|
||||
const listJson = listRes.ok ? await listRes.json() : null
|
||||
tokens = (Array.isArray(listJson?.tokens) ? listJson.tokens : []).filter(isTokenListToken)
|
||||
}
|
||||
return { chainId, network, tokens, loading: false, error: null }
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : 'Failed to load chain metadata'
|
||||
return { chainId, network: null, tokens: [], loading: false, error: message }
|
||||
}
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const initial = WALLET_IMPORT_CHAIN_IDS.map((chainId) => ({
|
||||
chainId,
|
||||
network: null,
|
||||
tokens: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
}))
|
||||
if (active) setChains(initial)
|
||||
|
||||
const loaded = await Promise.all(WALLET_IMPORT_CHAIN_IDS.map((chainId) => loadChain(chainId)))
|
||||
if (active) setChains(loaded)
|
||||
})()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const featuredByChain = useMemo(() => {
|
||||
return new Map(
|
||||
chains.map((row) => {
|
||||
const featuredSymbols = new Set(WALLET_FEATURED_SYMBOLS_BY_CHAIN[row.chainId] ?? [])
|
||||
const featured = row.tokens.filter((token) => featuredSymbols.has(token.symbol))
|
||||
return [row.chainId, featured.length > 0 ? featured : row.tokens.slice(0, 8)]
|
||||
}),
|
||||
)
|
||||
}, [chains])
|
||||
|
||||
const switchOrAddChain = async (network: WalletChain) => {
|
||||
const ethereum = resolveEthereum()
|
||||
if (!ethereum) {
|
||||
setError('No wallet provider found. Use MetaMask mobile in-app browser or WalletConnect.')
|
||||
return false
|
||||
}
|
||||
try {
|
||||
await ethereum.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: network.chainId }] })
|
||||
return true
|
||||
} catch (e) {
|
||||
const err = e as { code?: number; message?: string }
|
||||
if (err.code !== 4902) {
|
||||
setError(err.message || `Failed to switch to ${network.chainName}.`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
try {
|
||||
await ethereum.request({ method: 'wallet_addEthereumChain', params: [chainParamsForWallet(network)] })
|
||||
return true
|
||||
} catch (e) {
|
||||
const err = e as { message?: string }
|
||||
setError(err.message || `Failed to add ${network.chainName}.`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const watchTokensSequentially = async (
|
||||
chainId: number,
|
||||
tokens: TokenListToken[],
|
||||
label: string,
|
||||
startIndex = 0,
|
||||
priorAdded = 0,
|
||||
) => {
|
||||
setError(null)
|
||||
if (startIndex === 0) {
|
||||
setStatus(null)
|
||||
setPendingFlow(null)
|
||||
}
|
||||
setProgress(null)
|
||||
|
||||
const ethereum = resolveEthereum()
|
||||
if (!ethereum) {
|
||||
setError('No wallet provider found. Use MetaMask mobile in-app browser or WalletConnect.')
|
||||
return
|
||||
}
|
||||
|
||||
const row = chains.find((entry) => entry.chainId === chainId)
|
||||
if (!row?.network) {
|
||||
setError(`Network metadata for ${chainLabel(chainId)} is not available yet.`)
|
||||
return
|
||||
}
|
||||
|
||||
const switched = await switchOrAddChain(row.network)
|
||||
if (!switched) return
|
||||
|
||||
const validTokens = dedupeWalletWatchTokens(tokens.filter(isTokenListToken))
|
||||
if (validTokens.length === 0) {
|
||||
setError(`No token metadata is available for ${chainLabel(chainId)}.`)
|
||||
return
|
||||
}
|
||||
|
||||
const batch = await runWatchAssetBatch(ethereum, validTokens, startIndex, {
|
||||
mobile: mobileWalletContext,
|
||||
onProgress: (current, total) => setProgress({ current, total, chainId }),
|
||||
})
|
||||
|
||||
const totalAdded = priorAdded + batch.addedCount
|
||||
setProgress(null)
|
||||
|
||||
if (batch.stoppedEarly) {
|
||||
setError(batch.errorMessage || 'Token import stopped.')
|
||||
setPendingFlow(null)
|
||||
setStatus(`${totalAdded} of ${validTokens.length} ${label} requests were accepted before the flow stopped.`)
|
||||
return
|
||||
}
|
||||
|
||||
if (batch.nextIndex < validTokens.length) {
|
||||
setPendingFlow({
|
||||
chainId,
|
||||
tokens: validTokens,
|
||||
label,
|
||||
nextIndex: batch.nextIndex,
|
||||
totalAdded,
|
||||
})
|
||||
setStatus(
|
||||
`${totalAdded} of ${validTokens.length} on ${chainLabel(chainId)}. Tap Continue on that chain card for the next mobile prompts.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setPendingFlow(null)
|
||||
setStatus(`${totalAdded} of ${validTokens.length} ${label} token requests were accepted on ${chainLabel(chainId)}.`)
|
||||
}
|
||||
|
||||
const continuePendingFlow = async () => {
|
||||
if (!pendingFlow) return
|
||||
const { chainId, tokens, label, nextIndex, totalAdded } = pendingFlow
|
||||
await watchTokensSequentially(chainId, tokens, label, nextIndex, totalAdded)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-4 rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 sm:p-5">
|
||||
<h2 className="text-lg font-semibold">Multi-chain token import</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Add canonical tokens on other supported networks. Each chain switches your wallet first, then runs sequential
|
||||
EIP-747 <code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">wallet_watchAsset</code> prompts.
|
||||
{mobileWalletContext ? ' On mobile, two prompts run per tap — use Continue on the chain card.' : null}
|
||||
</p>
|
||||
|
||||
{pendingFlow ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void continuePendingFlow()}
|
||||
className={`rounded bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Continue {chainLabel(pendingFlow.chainId)} ({pendingFlow.totalAdded} added, {pendingFlow.tokens.length - pendingFlow.nextIndex} left)
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{chains.map((row) => {
|
||||
const featured = featuredByChain.get(row.chainId) ?? []
|
||||
return (
|
||||
<div key={row.chainId} className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">{chainLabel(row.chainId)}</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{row.loading
|
||||
? 'Loading token metadata…'
|
||||
: row.error
|
||||
? row.error
|
||||
: `${row.tokens.length} canonical tokens · ${featured.length} featured`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!row.network || row.loading}
|
||||
onClick={() => row.network && void switchOrAddChain(row.network).then((ok) => ok && setStatus(`Switched to ${chainLabel(row.chainId)}.`))}
|
||||
className={`rounded bg-gray-600 px-3 py-2 text-xs font-medium text-white hover:bg-gray-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Switch chain
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={row.loading || featured.length === 0 || pendingFlow !== null}
|
||||
onClick={() => void watchTokensSequentially(row.chainId, featured, `featured ${chainLabel(row.chainId)}`)}
|
||||
className={`rounded bg-primary-600 px-3 py-2 text-xs font-medium text-white hover:bg-primary-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add featured
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={row.loading || row.tokens.length === 0 || pendingFlow !== null}
|
||||
onClick={() => void watchTokensSequentially(row.chainId, row.tokens, chainLabel(row.chainId))}
|
||||
className={`rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-800 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{progress?.chainId === row.chainId ? (
|
||||
<div className="mt-3 text-xs text-gray-600 dark:text-gray-400">
|
||||
Prompt {progress.current} of {progress.total}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{status ? <p className="text-sm text-green-600 dark:text-green-400">{status}</p> : null}
|
||||
{error ? <p className="text-sm text-red-600 dark:text-red-400">{error}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
116
frontend/src/components/wallet/WalletFundedTokenListing.tsx
Normal file
116
frontend/src/components/wallet/WalletFundedTokenListing.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { FundedWalletTokenRow } from '@/utils/walletFundedTokenListing'
|
||||
import {
|
||||
formatFundedRowBalanceUsd,
|
||||
formatFundedRowUnitPrice,
|
||||
} from '@/utils/walletFundedTokenListing'
|
||||
|
||||
function formatLiquidityUsd(value: number | undefined): string {
|
||||
if (value == null || !Number.isFinite(value)) return '—'
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: value >= 100 ? 0 : 2,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
type WalletFundedTokenListingProps = {
|
||||
rows: FundedWalletTokenRow[]
|
||||
walletAddress?: string | null
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export default function WalletFundedTokenListing({
|
||||
rows,
|
||||
walletAddress,
|
||||
loading = false,
|
||||
error = null,
|
||||
}: WalletFundedTokenListingProps) {
|
||||
const erc20Count = rows.filter((row) => row.kind === 'erc20').length
|
||||
const hasNative = rows.some((row) => row.kind === 'native')
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-lg border border-emerald-200 bg-emerald-50/40 p-4 dark:border-emerald-900/50 dark:bg-emerald-950/20">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Funded Chain 138 holdings</div>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Native ETH appears first with the canonical ETH logo. ERC-20 rows use explorer market-batch pricing (unit price,
|
||||
balance USD, visible liquidity). MetaMask may still show "-" for custom tokens until upstream price
|
||||
providers list Chain 138 assets.
|
||||
</p>
|
||||
|
||||
{walletAddress ? (
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Wallet {walletAddress.slice(0, 6)}…{walletAddress.slice(-4)} · {hasNative ? 'ETH + ' : ''}
|
||||
{erc20Count} ERC-20{erc20Count === 1 ? '' : 's'} with balance
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">Loading on-chain balances and market prices…</p>
|
||||
) : null}
|
||||
{error ? <p className="mt-3 text-sm text-red-700 dark:text-red-300">{error}</p> : null}
|
||||
|
||||
{!loading && rows.length === 0 && !error ? (
|
||||
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
No funded catalog tokens found yet. Connect MetaMask and refresh this list.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{rows.length > 0 ? (
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-emerald-200/80 text-xs uppercase tracking-wide text-gray-500 dark:border-emerald-900/60 dark:text-gray-400">
|
||||
<th className="px-2 py-2 font-semibold">Token</th>
|
||||
<th className="px-2 py-2 font-semibold">Balance</th>
|
||||
<th className="px-2 py-2 font-semibold">Unit price</th>
|
||||
<th className="px-2 py-2 font-semibold">Balance USD</th>
|
||||
<th className="px-2 py-2 font-semibold">Liquidity</th>
|
||||
<th className="px-2 py-2 font-semibold">MetaMask</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr
|
||||
key={row.kind === 'native' ? 'native-eth' : row.address!}
|
||||
className="border-b border-emerald-100/80 dark:border-emerald-900/40"
|
||||
>
|
||||
<td className="px-2 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={row.logoURI}
|
||||
alt=""
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8 rounded-full border border-gray-200 bg-white object-contain dark:border-gray-700"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">
|
||||
{row.symbol}
|
||||
{row.kind === 'native' ? (
|
||||
<span className="ml-2 text-xs font-normal text-emerald-700 dark:text-emerald-300">
|
||||
native
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{row.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-3 font-medium text-gray-900 dark:text-white">{row.balanceLabel}</td>
|
||||
<td className="px-2 py-3 text-gray-700 dark:text-gray-300">{formatFundedRowUnitPrice(row)}</td>
|
||||
<td className="px-2 py-3 text-gray-700 dark:text-gray-300">{formatFundedRowBalanceUsd(row)}</td>
|
||||
<td className="px-2 py-3 text-gray-700 dark:text-gray-300">{formatLiquidityUsd(row.liquidityUsd)}</td>
|
||||
<td className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400">
|
||||
{row.kind === 'native' ? 'Automatic on Chain 138' : 'EIP-747 add'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import LpPositionPanel from '@/components/wallet/LpPositionPanel'
|
||||
import type {
|
||||
CapabilitiesCatalog,
|
||||
FetchMetadata,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
toggleWatchlistEntry,
|
||||
writeWatchlistToStorage,
|
||||
} from '@/utils/watchlist'
|
||||
import { MOBILE_WALLET_BUTTON_CLASS } from '@/utils/walletProviderEnv'
|
||||
|
||||
interface WalletPageProps {
|
||||
initialNetworks?: NetworksCatalog | null
|
||||
@@ -142,6 +144,11 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
? isWatchlistEntry(watchlistEntries, walletSession.address)
|
||||
: false
|
||||
|
||||
const lpHintAddresses = useMemo(
|
||||
() => tokenBalances.map((balance) => balance.token_address).filter(Boolean),
|
||||
[tokenBalances],
|
||||
)
|
||||
|
||||
const loadWalletSnapshot = useCallback(async (address: string) => {
|
||||
const [infoResponse, transactionsResponse, balancesResponse, transfersResponse] = await Promise.all([
|
||||
addressesApi.getSafe(138, address),
|
||||
@@ -310,7 +317,7 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
type="button"
|
||||
onClick={() => void handleConnectWallet()}
|
||||
disabled={connectingWallet}
|
||||
className="rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
className={`rounded-lg bg-primary-600 px-4 py-3 text-sm font-semibold text-white hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-70 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
{connectingWallet ? 'Connecting wallet…' : 'Connect wallet'}
|
||||
</button>
|
||||
@@ -323,7 +330,7 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
? 'Pair a mobile wallet via WalletConnect QR'
|
||||
: 'Set WALLETCONNECT_PROJECT_ID on the explorer backend to enable WalletConnect'
|
||||
}
|
||||
className="rounded-lg border border-indigo-300 px-3 py-2 text-sm font-semibold text-indigo-700 hover:bg-indigo-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-60 dark:border-indigo-800 dark:text-indigo-300 dark:hover:bg-indigo-950/20"
|
||||
className={`inline-flex items-center justify-center rounded-lg border border-indigo-300 px-4 py-3 text-sm font-semibold text-indigo-700 hover:bg-indigo-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-60 dark:border-indigo-800 dark:text-indigo-300 dark:hover:bg-indigo-950/20 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
{connectingWalletConnect ? 'Opening WalletConnect…' : 'WalletConnect'}
|
||||
</button>
|
||||
@@ -518,6 +525,15 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<LpPositionPanel
|
||||
chainId={138}
|
||||
address={walletSession.address}
|
||||
hintAddresses={lpHintAddresses}
|
||||
title="LP positions (Chain 138)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -58,9 +58,8 @@ export const explorerFeaturePages = {
|
||||
{
|
||||
title: 'Visual command center',
|
||||
description: 'Open the interactive topology map for Chain 138, CCIP, Alltra, and adjacent integrations.',
|
||||
href: '/chain138-command-center.html',
|
||||
href: '/topology',
|
||||
label: 'Open command center',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: 'Routes & liquidity',
|
||||
@@ -119,9 +118,8 @@ export const explorerFeaturePages = {
|
||||
{
|
||||
title: 'Visual command center',
|
||||
description: 'Use the interactive topology map for contract placement, hub flow, and system context.',
|
||||
href: '/chain138-command-center.html',
|
||||
href: '/topology',
|
||||
label: 'Open command center',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: 'Wallet tools',
|
||||
@@ -211,9 +209,8 @@ export const explorerFeaturePages = {
|
||||
{
|
||||
title: 'Visual command center',
|
||||
description: 'Open the graphical deployment and integration topology in a dedicated page.',
|
||||
href: '/chain138-command-center.html',
|
||||
href: '/topology',
|
||||
label: 'Open command center',
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -227,9 +224,8 @@ export const explorerFeaturePages = {
|
||||
{
|
||||
title: 'Visual command center',
|
||||
description: 'Open the topology map for Chain 138, CCIP, Alltra, OP Stack, and service flows.',
|
||||
href: '/chain138-command-center.html',
|
||||
href: '/topology',
|
||||
label: 'Open command center',
|
||||
external: true,
|
||||
},
|
||||
{
|
||||
title: 'Bridge monitoring',
|
||||
@@ -297,9 +293,8 @@ export const explorerFeaturePages = {
|
||||
{
|
||||
title: 'Visual command center',
|
||||
description: 'Open the dedicated interactive topology asset in a new tab.',
|
||||
href: '/chain138-command-center.html',
|
||||
href: '/topology',
|
||||
label: 'Open command center',
|
||||
external: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -337,6 +332,11 @@ export const explorerOperationsSurfaces: ExplorerOperationsSurface[] = [
|
||||
label: 'WETH',
|
||||
description: 'Wrapped-asset references and bridge context.',
|
||||
},
|
||||
{
|
||||
href: '/protocols',
|
||||
label: 'Protocols',
|
||||
description: 'Official Chain 138 deploy catalog (DODO, Uni, CCIP).',
|
||||
},
|
||||
{
|
||||
href: '/pools',
|
||||
label: 'Pools',
|
||||
@@ -357,6 +357,11 @@ export const explorerOperationsSurfaces: ExplorerOperationsSurface[] = [
|
||||
label: 'Operator',
|
||||
description: 'Track 4 relay, route, and planner shortcuts.',
|
||||
},
|
||||
{
|
||||
href: '/topology',
|
||||
label: 'Command center',
|
||||
description: 'Static Mermaid topology map (Chain 138 hub, CCIP, cW mesh).',
|
||||
},
|
||||
]
|
||||
|
||||
export const explorerPublicApiLinks = [
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
type ContractProfile,
|
||||
} from '@/services/api/contracts'
|
||||
import { formatTimestamp, formatTokenAmount, formatWeiAsEth } from '@/utils/format'
|
||||
import { isEnsName, resolveEnsAddress } from '@/utils/ens'
|
||||
import { resolveAddressFromRegistryEns } from '@/utils/web3IdentityRegistry'
|
||||
import { DetailRow } from '@/components/common/DetailRow'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import PostureBadge from '@/components/common/PostureBadge'
|
||||
@@ -30,14 +32,28 @@ import {
|
||||
} from '@/utils/watchlist'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import PaginationControls from '@/components/common/PaginationControls'
|
||||
import SectionTabs, { type SectionTab } from '@/components/common/SectionTabs'
|
||||
import SectionTabs, { sectionTabPanelProps, type SectionTab } from '@/components/common/SectionTabs'
|
||||
import { useDetailTabQuery } from '@/utils/useDetailTabQuery'
|
||||
|
||||
const ADDRESS_DETAIL_TABS_ID = 'address-detail'
|
||||
import GruStandardsCard from '@/components/common/GruStandardsCard'
|
||||
import ContractCodeWorkspace from '@/components/explorer/ContractCodeWorkspace'
|
||||
import ContractVerificationCallout from '@/components/explorer/ContractVerificationCallout'
|
||||
import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru'
|
||||
import { getGruExplorerMetadata } from '@/services/api/gruExplorerData'
|
||||
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
|
||||
import { tokensApi } from '@/services/api/tokens'
|
||||
import type { TokenListToken } from '@/services/api/config'
|
||||
import { estimateNativeUsdValue, getNativeAssetDescriptor, getNativeAssetMarketSafe } from '@/services/api/nativeAssetPricing'
|
||||
import {
|
||||
buildCanonicalAddressSet,
|
||||
isCanonicalTokenAddress,
|
||||
} from '@/utils/canonicalTokens'
|
||||
import {
|
||||
estimateTokenBalanceUsd,
|
||||
formatBalanceUsdLabel,
|
||||
resolveTokenMarketForBalance,
|
||||
} from '@/utils/tokenMarket'
|
||||
|
||||
function isValidAddress(value: string) {
|
||||
return /^0x[a-fA-F0-9]{40}$/.test(value)
|
||||
@@ -71,8 +87,9 @@ export default function AddressDetailPage() {
|
||||
const [methodInputs, setMethodInputs] = useState<Record<string, string[]>>({})
|
||||
const [tokenMarkets, setTokenMarkets] = useState<Record<string, TokenAggregationTokenSnapshot>>({})
|
||||
const [nativeAssetPriceUsd, setNativeAssetPriceUsd] = useState<number | undefined>()
|
||||
const [curatedTokens, setCuratedTokens] = useState<TokenListToken[]>([])
|
||||
const [transferHistoricalUsd, setTransferHistoricalUsd] = useState<Record<string, number>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'contract' | 'balances' | 'transfers' | 'transactions'>('balances')
|
||||
const [balancePage, setBalancePage] = useState(1)
|
||||
const [transferPage, setTransferPage] = useState(1)
|
||||
const [transactionPage, setTransactionPage] = useState(1)
|
||||
@@ -131,6 +148,19 @@ export default function AddressDetailPage() {
|
||||
}
|
||||
}, [chainId, address])
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady || !address || isValidAddressParam) return
|
||||
if (!isEnsName(address)) return
|
||||
|
||||
void (async () => {
|
||||
const fromRegistry = resolveAddressFromRegistryEns(address)
|
||||
const resolved = fromRegistry || (await resolveEnsAddress(address))
|
||||
if (resolved) {
|
||||
await router.replace(`/addresses/${resolved}`)
|
||||
}
|
||||
})()
|
||||
}, [address, isValidAddressParam, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady || !address) {
|
||||
setLoading(router.isReady ? false : true)
|
||||
@@ -202,6 +232,64 @@ export default function AddressDetailPage() {
|
||||
}
|
||||
}, [chainId])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
tokensApi.listForSurface('catalog', chainId).then(({ ok, data }) => {
|
||||
if (active) {
|
||||
setCuratedTokens(ok ? data : [])
|
||||
}
|
||||
}).catch(() => {
|
||||
if (active) {
|
||||
setCuratedTokens([])
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [chainId])
|
||||
|
||||
const canonicalAddressSet = useMemo(() => buildCanonicalAddressSet(curatedTokens), [curatedTokens])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const transfersWithTimestamp = tokenTransfers
|
||||
.filter((transfer) => transfer.timestamp && transfer.token_address)
|
||||
.slice(0, 8)
|
||||
|
||||
if (transfersWithTimestamp.length === 0) {
|
||||
setTransferHistoricalUsd({})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}
|
||||
|
||||
void Promise.all(transfersWithTimestamp.map(async (transfer) => {
|
||||
const key = `${transfer.token_address.toLowerCase()}:${transfer.timestamp}`
|
||||
const result = await tokenAggregationApi.getPriceAtSafe(
|
||||
chainId,
|
||||
transfer.token_address,
|
||||
transfer.timestamp as string,
|
||||
)
|
||||
if (!active || !result.ok || result.data?.priceUsd == null) {
|
||||
return null
|
||||
}
|
||||
return [key, result.data.priceUsd] as const
|
||||
})).then((rows) => {
|
||||
if (!active) return
|
||||
const next: Record<string, number> = {}
|
||||
for (const row of rows) {
|
||||
if (row) {
|
||||
next[row[0]] = row[1]
|
||||
}
|
||||
}
|
||||
setTransferHistoricalUsd(next)
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [chainId, tokenTransfers])
|
||||
|
||||
const watchlistAddress = normalizeWatchlistAddress(addressInfo?.address || address)
|
||||
const isSavedToWatchlist = watchlistAddress
|
||||
? isWatchlistEntry(watchlistEntries, watchlistAddress)
|
||||
@@ -380,6 +468,9 @@ export default function AddressDetailPage() {
|
||||
{gruMetadata ? <EntityBadge label="GRU" tone="success" /> : null}
|
||||
{gruMetadata?.x402Ready ? <PostureBadge label="x402 ready" tone="info" /> : null}
|
||||
{gruMetadata?.iso20022Ready ? <PostureBadge label="ISO-20022" tone="info" /> : null}
|
||||
{balance.token_address && !isCanonicalTokenAddress(balance.token_address, canonicalAddressSet) && balance.token_symbol ? (
|
||||
<EntityBadge label="non-canonical clone" tone="warning" />
|
||||
) : null}
|
||||
</div>
|
||||
{balance.token_name && balance.token_symbol && (
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{balance.token_name}</div>
|
||||
@@ -390,9 +481,20 @@ export default function AddressDetailPage() {
|
||||
},
|
||||
{
|
||||
header: 'Balance',
|
||||
accessor: (balance: AddressTokenBalance) => (
|
||||
formatTokenAmount(balance.value, balance.token_decimals, balance.token_symbol)
|
||||
),
|
||||
accessor: (balance: AddressTokenBalance) => {
|
||||
const market = resolveTokenMarketForBalance(balance, tokenMarkets)
|
||||
const balanceUsd = market.pricingKind === 'lp-share'
|
||||
? undefined
|
||||
: estimateTokenBalanceUsd(balance.value, balance.token_decimals, market.priceUsd)
|
||||
return (
|
||||
<div className="space-y-1 text-sm">
|
||||
<div>{formatTokenAmount(balance.value, balance.token_decimals, balance.token_symbol)}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatBalanceUsdLabel(market.priceUsd, balanceUsd, { pricingKind: market.pricingKind })}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'Supply',
|
||||
@@ -405,12 +507,12 @@ export default function AddressDetailPage() {
|
||||
{
|
||||
header: 'Current Price',
|
||||
accessor: (balance: AddressTokenBalance) => {
|
||||
const market = tokenMarkets[balance.token_address.toLowerCase()]?.market
|
||||
const market = resolveTokenMarketForBalance(balance, tokenMarkets)
|
||||
return (
|
||||
<div className="space-y-1 text-sm">
|
||||
<div>{formatUsd(market?.priceUsd)}</div>
|
||||
<div>{formatUsd(market.priceUsd)}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Liq. {formatUsd(market?.liquidityUsd)}
|
||||
Liq. {formatUsd(market.liquidityUsd)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -464,9 +566,34 @@ export default function AddressDetailPage() {
|
||||
},
|
||||
{
|
||||
header: 'Amount',
|
||||
accessor: (transfer: AddressTokenTransfer) => (
|
||||
formatTokenAmount(transfer.value, transfer.token_decimals, transfer.token_symbol)
|
||||
),
|
||||
accessor: (transfer: AddressTokenTransfer) => {
|
||||
const pseudoBalance: AddressTokenBalance = {
|
||||
token_address: transfer.token_address,
|
||||
token_decimals: transfer.token_decimals,
|
||||
token_symbol: transfer.token_symbol,
|
||||
value: transfer.value,
|
||||
}
|
||||
const market = resolveTokenMarketForBalance(pseudoBalance, tokenMarkets)
|
||||
const historicalKey = transfer.timestamp
|
||||
? `${transfer.token_address.toLowerCase()}:${transfer.timestamp}`
|
||||
: ''
|
||||
const historicalPrice = historicalKey ? transferHistoricalUsd[historicalKey] : undefined
|
||||
const effectivePrice = historicalPrice ?? market.priceUsd
|
||||
const amountUsd = market.pricingKind === 'lp-share'
|
||||
? undefined
|
||||
: estimateTokenBalanceUsd(transfer.value, transfer.token_decimals, effectivePrice)
|
||||
return (
|
||||
<div className="space-y-1 text-sm">
|
||||
<div>{formatTokenAmount(transfer.value, transfer.token_decimals, transfer.token_symbol)}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatBalanceUsdLabel(effectivePrice, amountUsd, {
|
||||
pricingKind: market.pricingKind,
|
||||
atTransfer: historicalPrice != null,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
header: 'When',
|
||||
@@ -475,12 +602,18 @@ export default function AddressDetailPage() {
|
||||
{
|
||||
header: 'Current Price',
|
||||
accessor: (transfer: AddressTokenTransfer) => {
|
||||
const market = tokenMarkets[transfer.token_address.toLowerCase()]?.market
|
||||
const pseudoBalance: AddressTokenBalance = {
|
||||
token_address: transfer.token_address,
|
||||
token_decimals: transfer.token_decimals,
|
||||
token_symbol: transfer.token_symbol,
|
||||
value: transfer.value,
|
||||
}
|
||||
const market = resolveTokenMarketForBalance(pseudoBalance, tokenMarkets)
|
||||
return (
|
||||
<div className="space-y-1 text-sm">
|
||||
<div>{formatUsd(market?.priceUsd)}</div>
|
||||
<div>{formatUsd(market.priceUsd)}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Liq. {formatUsd(market?.liquidityUsd)}
|
||||
Liq. {formatUsd(market.liquidityUsd)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -508,12 +641,21 @@ export default function AddressDetailPage() {
|
||||
).length
|
||||
const nativeAssetSymbol = getNativeAssetDescriptor(chainId).symbol
|
||||
const nativeBalanceUsd = estimateNativeUsdValue(addressInfo?.balance, nativeAssetPriceUsd)
|
||||
const tabs: SectionTab<typeof activeTab>[] = [
|
||||
...(addressInfo?.is_contract ? [{ id: 'contract' as const, label: 'Contract' }] : []),
|
||||
{ id: 'balances', label: 'Balances', count: tokenBalances.length },
|
||||
{ id: 'transfers', label: 'Transfers', count: tokenTransfers.length },
|
||||
{ id: 'transactions', label: 'Transactions', count: transactions.length },
|
||||
]
|
||||
const tabs = useMemo<SectionTab<'contract' | 'balances' | 'transfers' | 'transactions'>[]>(
|
||||
() => [
|
||||
...(addressInfo?.is_contract ? [{ id: 'contract' as const, label: 'Contract' }] : []),
|
||||
{ id: 'balances', label: 'Balances', count: tokenBalances.length },
|
||||
{ id: 'transfers', label: 'Transfers', count: tokenTransfers.length },
|
||||
{ id: 'transactions', label: 'Transactions', count: transactions.length },
|
||||
],
|
||||
[addressInfo?.is_contract, tokenBalances.length, tokenTransfers.length, transactions.length],
|
||||
)
|
||||
const addressTabIds = useMemo(
|
||||
() => tabs.map((tab) => tab.id),
|
||||
[tabs],
|
||||
)
|
||||
const defaultAddressTab = addressInfo?.is_contract ? 'contract' : 'balances'
|
||||
const { activeTab, setActiveTab } = useDetailTabQuery(addressTabIds, defaultAddressTab, address)
|
||||
const balancePageCount = Math.max(1, Math.ceil(tokenBalances.length / pageSize))
|
||||
const transferPageCount = Math.max(1, Math.ceil(tokenTransfers.length / pageSize))
|
||||
const transactionPageCount = Math.max(1, Math.ceil(transactions.length / pageSize))
|
||||
@@ -534,7 +676,6 @@ export default function AddressDetailPage() {
|
||||
setBalancePage(1)
|
||||
setTransferPage(1)
|
||||
setTransactionPage(1)
|
||||
setActiveTab(addressInfo?.is_contract ? 'contract' : 'balances')
|
||||
}, [address, addressInfo?.is_contract])
|
||||
|
||||
return (
|
||||
@@ -603,7 +744,7 @@ export default function AddressDetailPage() {
|
||||
<Card title="Address Information" className="mb-6">
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Address">
|
||||
<Address address={addressInfo.address} />
|
||||
<Address address={addressInfo.address} label={addressInfo.label} showENS />
|
||||
</DetailRow>
|
||||
{addressInfo.balance && (
|
||||
<DetailRow label="Coin Balance">
|
||||
@@ -674,9 +815,20 @@ export default function AddressDetailPage() {
|
||||
<ContractVerificationCallout address={addressInfo.address} verified={Boolean(addressInfo.is_verified)} />
|
||||
) : null}
|
||||
|
||||
<SectionTabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="mb-6" />
|
||||
<SectionTabs
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onChange={setActiveTab}
|
||||
className="mb-6"
|
||||
idPrefix={ADDRESS_DETAIL_TABS_ID}
|
||||
ariaLabel="Address details"
|
||||
/>
|
||||
|
||||
{activeTab === 'contract' && addressInfo.is_contract && (
|
||||
<div
|
||||
{...sectionTabPanelProps(ADDRESS_DETAIL_TABS_ID, 'contract', activeTab)}
|
||||
className={addressInfo.is_contract ? undefined : 'hidden'}
|
||||
>
|
||||
{addressInfo.is_contract ? (
|
||||
<Card title="Contract Profile" className="mb-6">
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Interaction Surface">
|
||||
@@ -889,15 +1041,17 @@ export default function AddressDetailPage() {
|
||||
)}
|
||||
</dl>
|
||||
</Card>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{activeTab === 'contract' && addressInfo.is_contract && contractProfile ? (
|
||||
{contractProfile ? (
|
||||
<ContractCodeWorkspace address={addressInfo.address} profile={contractProfile} />
|
||||
) : null}
|
||||
|
||||
{activeTab === 'contract' && gruProfile ? <div className="mb-6"><GruStandardsCard profile={gruProfile} /></div> : null}
|
||||
{gruProfile ? <div className="mb-6"><GruStandardsCard profile={gruProfile} /></div> : null}
|
||||
</div>
|
||||
|
||||
{activeTab === 'balances' ? <Card title="Token Balances" className="mb-6">
|
||||
<div {...sectionTabPanelProps(ADDRESS_DETAIL_TABS_ID, 'balances', activeTab)}>
|
||||
<Card title="Token Balances" className="mb-6">
|
||||
{gruBalanceCount > 0 ? (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>{gruBalanceCount} visible token balance{gruBalanceCount === 1 ? '' : 's'} look GRU-aware.</span>
|
||||
@@ -918,9 +1072,11 @@ export default function AddressDetailPage() {
|
||||
onPageChange={setBalancePage}
|
||||
label="Token balances"
|
||||
/>
|
||||
</Card> : null}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{activeTab === 'transfers' ? <Card title="Recent Token Transfers" className="mb-6">
|
||||
<div {...sectionTabPanelProps(ADDRESS_DETAIL_TABS_ID, 'transfers', activeTab)}>
|
||||
<Card title="Recent Token Transfers" className="mb-6">
|
||||
{gruTransferCount > 0 ? (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>{gruTransferCount} recent transfer asset{gruTransferCount === 1 ? '' : 's'} carry GRU posture in the explorer.</span>
|
||||
@@ -944,9 +1100,11 @@ export default function AddressDetailPage() {
|
||||
onPageChange={setTransferPage}
|
||||
label="Token transfers"
|
||||
/>
|
||||
</Card> : null}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{activeTab === 'transactions' ? <Card title="Transactions">
|
||||
<div {...sectionTabPanelProps(ADDRESS_DETAIL_TABS_ID, 'transactions', activeTab)}>
|
||||
<Card title="Transactions">
|
||||
<Table
|
||||
columns={transactionColumns}
|
||||
data={pagedTransactions}
|
||||
@@ -959,7 +1117,8 @@ export default function AddressDetailPage() {
|
||||
onPageChange={setTransactionPage}
|
||||
label="Transactions"
|
||||
/>
|
||||
</Card> : null}
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { readWatchlistFromStorage } from '@/utils/watchlist'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||
import { normalizeTransaction } from '@/services/api/blockscout'
|
||||
import { isEnsName, resolveEnsAddress } from '@/utils/ens'
|
||||
import { resolveAddressFromRegistryEns } from '@/utils/web3IdentityRegistry'
|
||||
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
@@ -20,6 +22,11 @@ function normalizeAddress(value: string) {
|
||||
return /^0x[a-fA-F0-9]{40}$/.test(trimmed) ? trimmed : ''
|
||||
}
|
||||
|
||||
function canOpenAddressQuery(value: string) {
|
||||
const trimmed = value.trim()
|
||||
return Boolean(normalizeAddress(trimmed) || isEnsName(trimmed))
|
||||
}
|
||||
|
||||
interface AddressesPageProps {
|
||||
initialRecentTransactions: Transaction[]
|
||||
initialLatestBlocks: Array<{ number: number; timestamp: string }>
|
||||
@@ -127,11 +134,31 @@ export default function AddressesPage({
|
||||
return addresses
|
||||
}, [recentTransactions])
|
||||
|
||||
const handleOpenAddress = (event: React.FormEvent) => {
|
||||
const [opening, setOpening] = useState(false)
|
||||
|
||||
const handleOpenAddress = async (event: React.FormEvent) => {
|
||||
event.preventDefault()
|
||||
const normalized = normalizeAddress(query)
|
||||
if (!normalized) return
|
||||
router.push(`/addresses/${normalized}`)
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return
|
||||
|
||||
const normalized = normalizeAddress(trimmed)
|
||||
if (normalized) {
|
||||
router.push(`/addresses/${normalized}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!isEnsName(trimmed)) return
|
||||
|
||||
setOpening(true)
|
||||
try {
|
||||
const fromRegistry = resolveAddressFromRegistryEns(trimmed)
|
||||
const resolved = fromRegistry || (await resolveEnsAddress(trimmed))
|
||||
if (resolved) {
|
||||
router.push(`/addresses/${resolved}`)
|
||||
}
|
||||
} finally {
|
||||
setOpening(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -164,19 +191,19 @@ export default function AddressesPage({
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="0x..."
|
||||
placeholder="0x... or name.eth"
|
||||
className="flex-1 rounded-lg border border-gray-300 px-4 py-2 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!normalizeAddress(query)}
|
||||
disabled={!canOpenAddressQuery(query) || opening}
|
||||
className="rounded-lg bg-primary-600 px-6 py-2 text-white hover:bg-primary-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Open address
|
||||
{opening ? 'Resolving…' : 'Open address'}
|
||||
</button>
|
||||
</form>
|
||||
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
Open any Chain 138 address directly, or jump into your saved watchlist below.
|
||||
Open any Chain 138 address or resolve a mainnet `.eth` name, then jump into your saved watchlist below.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import { transactionsApi } from '@/services/api/transactions'
|
||||
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import PaginationControls from '@/components/common/PaginationControls'
|
||||
import { useListPageQuery } from '@/utils/useListPageQuery'
|
||||
import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats'
|
||||
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
|
||||
@@ -51,7 +53,7 @@ export default function BlocksPage({
|
||||
const [blocks, setBlocks] = useState<Block[]>(initialBlocks)
|
||||
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>(initialRecentTransactions)
|
||||
const [loading, setLoading] = useState(initialBlocks.length === 0)
|
||||
const [page, setPage] = useState(1)
|
||||
const { page, setPage } = useListPageQuery()
|
||||
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
|
||||
const loadBlocks = useCallback(async () => {
|
||||
@@ -206,25 +208,17 @@ export default function BlocksPage({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPagination && (
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={loading || page === 1}
|
||||
className="rounded bg-gray-200 px-4 py-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="px-3 py-2 text-sm sm:px-4">Page {page}</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={loading || !canGoNext}
|
||||
className="rounded bg-gray-200 px-4 py-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{showPagination ? (
|
||||
<PaginationControls
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
label="Blocks"
|
||||
ariaLabel="Blocks pagination"
|
||||
disabled={loading}
|
||||
hasNextPage={canGoNext}
|
||||
className="mt-6"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 grid gap-4 lg:grid-cols-2">
|
||||
<Card title="Keep Exploring">
|
||||
|
||||
@@ -30,6 +30,11 @@ const docsCards = [
|
||||
href: '/docs/transaction-review',
|
||||
description: 'See how the explorer scores transaction evidence quality, decode richness, asset posture, and counterparty traceability.',
|
||||
},
|
||||
{
|
||||
title: 'Official protocol contracts',
|
||||
href: '/protocols',
|
||||
description: 'Chain 138 DODO, UniV2/V3, CCIP, Multicall3, and integration addresses from the official deploy registry.',
|
||||
},
|
||||
{
|
||||
title: 'Liquidity access',
|
||||
href: '/liquidity',
|
||||
@@ -135,7 +140,7 @@ export default function DocsIndexPage() {
|
||||
Support: <a href="mailto:support@d-bis.org" className="text-primary-600 hover:underline">support@d-bis.org</a>
|
||||
</p>
|
||||
<p>
|
||||
Command center: <a href="/chain138-command-center.html" target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline">open visual map →</a>
|
||||
Command center: <Link href="/topology" className="text-primary-600 hover:underline">open visual map →</Link>
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
16
frontend/src/pages/pools/[address].tsx
Normal file
16
frontend/src/pages/pools/[address].tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
'use client'
|
||||
|
||||
import { useRouter } from 'next/router'
|
||||
import PoolDetailPage from '@/components/pools/PoolDetailPage'
|
||||
|
||||
export default function PoolAddressPage() {
|
||||
const router = useRouter()
|
||||
const raw = router.query.address
|
||||
const address = typeof raw === 'string' ? raw : ''
|
||||
|
||||
if (!router.isReady) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <PoolDetailPage poolAddress={address} />
|
||||
}
|
||||
174
frontend/src/pages/protocols/[id].tsx
Normal file
174
frontend/src/pages/protocols/[id].tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import OperationsSurfaceNav from '@/components/explorer/OperationsSurfaceNav'
|
||||
import { fetchOfficialProtocols, type OfficialProtocolRow, type ProtocolVerificationReport } from '@/services/api/officialProtocols'
|
||||
|
||||
export default function ProtocolDetailPage() {
|
||||
const router = useRouter()
|
||||
const protocolId = typeof router.query.id === 'string' ? router.query.id : ''
|
||||
const [protocol, setProtocol] = useState<OfficialProtocolRow | null>(null)
|
||||
const [forbidden, setForbidden] = useState<Array<{ id: string; description: string }>>([])
|
||||
const [verification, setVerification] = useState<ProtocolVerificationReport | null>(null)
|
||||
const [registryMeta, setRegistryMeta] = useState<{ schemaVersion?: string; updated?: string; generatedAt?: string }>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady || !protocolId) return
|
||||
let active = true
|
||||
void fetchOfficialProtocols(protocolId)
|
||||
.then((report) => {
|
||||
if (!active) return
|
||||
setProtocol(report?.protocol ?? report?.protocols?.[0] ?? null)
|
||||
setForbidden(report?.forbiddenProductionPatterns ?? [])
|
||||
setVerification(report?.verification ?? null)
|
||||
setRegistryMeta({
|
||||
schemaVersion: report?.schemaVersion,
|
||||
updated: report?.updated,
|
||||
generatedAt: report?.generatedAt,
|
||||
})
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [protocolId, router.isReady])
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<OperationsSurfaceNav />
|
||||
<PageIntro
|
||||
eyebrow="Official protocol"
|
||||
title={protocol?.id ?? (protocolId || 'Protocol')}
|
||||
description={
|
||||
protocol?.upstreamRepo
|
||||
? `Deployed from official upstream. ${protocol.contracts.length} on-chain addresses on Chain 138.`
|
||||
: 'Chain 138 official protocol contract catalog.'
|
||||
}
|
||||
actions={[
|
||||
{ href: '/protocols', label: 'All protocols' },
|
||||
{ href: '/liquidity', label: 'Liquidity' },
|
||||
{ href: `/token-aggregation/api/v1/report/official-protocols/${protocolId}`, label: 'JSON' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading…</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{!loading && !protocol ? (
|
||||
<Card title="Not found">
|
||||
<Link href="/protocols" className="text-primary-600 hover:underline">
|
||||
Back to protocols →
|
||||
</Link>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{protocol ? (
|
||||
<div className="space-y-6">
|
||||
{verification ? (
|
||||
<Card title="On-chain verification">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<EntityBadge
|
||||
label={verification.allVerified ? 'all contracts deployed' : 'review required'}
|
||||
tone={verification.allVerified ? 'success' : 'warning'}
|
||||
/>
|
||||
<EntityBadge label={`chain ${verification.chainId}`} tone="neutral" />
|
||||
{registryMeta.schemaVersion ? (
|
||||
<EntityBadge label={`registry ${registryMeta.schemaVersion}`} tone="info" className="normal-case tracking-normal" />
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
Live bytecode probe via Chain 138 RPC at {new Date(verification.verifiedAt).toLocaleString()}.
|
||||
{registryMeta.updated ? ` Registry updated ${registryMeta.updated}.` : ''}
|
||||
</p>
|
||||
<div className="mt-4 space-y-2">
|
||||
{verification.contracts.map((row) => (
|
||||
<div key={row.address} className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<EntityBadge
|
||||
label={row.status.replace('_', ' ')}
|
||||
tone={row.status === 'verified' ? 'success' : 'warning'}
|
||||
className="normal-case tracking-normal"
|
||||
/>
|
||||
<span className="font-medium text-gray-900 dark:text-white">{row.name}</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
code {row.codeSize} bytes
|
||||
{row.minCodeSize ? ` (min ${row.minCodeSize})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card title="Deployment metadata">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{protocol.requiredForProduction ? <EntityBadge label="required for production" tone="success" /> : null}
|
||||
{protocol.classification ? (
|
||||
<EntityBadge label={protocol.classification} tone="info" className="normal-case tracking-normal" />
|
||||
) : null}
|
||||
</div>
|
||||
{protocol.upstreamRepo ? (
|
||||
<p className="mt-3 text-sm">
|
||||
Upstream:{' '}
|
||||
<a href={protocol.upstreamRepo} target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline">
|
||||
{protocol.upstreamRepo}
|
||||
</a>
|
||||
</p>
|
||||
) : null}
|
||||
{protocol.deployScripts?.length ? (
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Deploy: {protocol.deployScripts.join(', ')}
|
||||
</p>
|
||||
) : null}
|
||||
{protocol.integrationNotes ? (
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">{protocol.integrationNotes}</p>
|
||||
) : null}
|
||||
</Card>
|
||||
|
||||
<Card title="Contracts">
|
||||
<div className="space-y-4">
|
||||
{protocol.contracts.map((contract) => (
|
||||
<div key={contract.name} className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-700">
|
||||
<div className="font-semibold text-gray-900 dark:text-white">{contract.name}</div>
|
||||
<div className="mt-2">
|
||||
<Address address={contract.address} truncate showCopy />
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{contract.inventoryKey ? (
|
||||
<EntityBadge label={contract.inventoryKey} tone="neutral" className="normal-case tracking-normal" />
|
||||
) : null}
|
||||
<Link href={`/addresses/${contract.address}`} className="text-xs text-primary-600 hover:underline">
|
||||
Explorer address →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{forbidden.length > 0 ? (
|
||||
<Card title="Forbidden in production">
|
||||
<ul className="list-disc space-y-2 pl-5 text-sm text-gray-700 dark:text-gray-300">
|
||||
{forbidden.slice(0, 5).map((row) => (
|
||||
<li key={row.id}>
|
||||
<span className="font-medium">{row.id}</span> — {row.description}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
96
frontend/src/pages/protocols/index.tsx
Normal file
96
frontend/src/pages/protocols/index.tsx
Normal file
@@ -0,0 +1,96 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card } from '@/libs/frontend-ui-primitives'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import OperationsSurfaceNav from '@/components/explorer/OperationsSurfaceNav'
|
||||
import { fetchOfficialProtocols, type OfficialProtocolsResponse } from '@/services/api/officialProtocols'
|
||||
|
||||
export default function ProtocolsIndexPage() {
|
||||
const [report, setReport] = useState<OfficialProtocolsResponse | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void fetchOfficialProtocols()
|
||||
.then((data) => {
|
||||
if (active) setReport(data)
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<OperationsSurfaceNav />
|
||||
<PageIntro
|
||||
eyebrow="Chain 138 infrastructure"
|
||||
title="Official protocol contracts"
|
||||
description="Production DODO, UniV2/V3, CCIP, Multicall3, and integration contracts deployed from official upstream sources — with forbidden pilot patterns called out."
|
||||
actions={[
|
||||
{ href: '/liquidity', label: 'Liquidity' },
|
||||
{ href: '/docs', label: 'Explorer docs' },
|
||||
{ href: '/token-aggregation/api/v1/report/official-protocols', label: 'JSON API' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading protocol registry…</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{!loading && !report ? (
|
||||
<Card title="Registry unavailable">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
The official protocol registry API is temporarily unavailable.
|
||||
</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{report ? (
|
||||
<div className="space-y-6">
|
||||
<Card title="Production guardrails">
|
||||
<ul className="list-disc space-y-2 pl-5 text-sm text-gray-700 dark:text-gray-300">
|
||||
{report.guardrails.slice(0, 4).map((line) => (
|
||||
<li key={line}>{line}</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card title={`Protocols (${report.count})`}>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{report.protocols.map((protocol) => (
|
||||
<Link
|
||||
key={protocol.id}
|
||||
href={`/protocols/${protocol.id}`}
|
||||
className="block rounded-2xl border border-gray-200 px-4 py-4 hover:border-primary-300 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-semibold text-gray-900 dark:text-white">{protocol.id}</span>
|
||||
{protocol.requiredForProduction ? (
|
||||
<EntityBadge label="production" tone="success" />
|
||||
) : null}
|
||||
{protocol.classification ? (
|
||||
<EntityBadge label={protocol.classification} tone="neutral" className="normal-case tracking-normal" />
|
||||
) : null}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
{protocol.contracts.length} contract{protocol.contracts.length === 1 ? '' : 's'}
|
||||
{protocol.upstreamRepo ? ' · official upstream' : ''}
|
||||
</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -10,16 +10,44 @@ import EntityBadge from '@/components/common/EntityBadge'
|
||||
import PostureBadge from '@/components/common/PostureBadge'
|
||||
import {
|
||||
inferDirectSearchTarget,
|
||||
inferEnsSearchTarget,
|
||||
inferTokenSearchTarget,
|
||||
normalizeExplorerSearchResults,
|
||||
searchWrappedTransportTokens,
|
||||
shouldDeferChain138AddressJump,
|
||||
suggestCuratedTokens,
|
||||
type RawExplorerSearchItem,
|
||||
type WrappedTransportRegistryRow,
|
||||
} from '@/utils/search'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||
import { fetchTokenListForSurface } from '@/services/api/tokenListSurfaces'
|
||||
import { useUiMode } from '@/components/common/UiModeContext'
|
||||
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
|
||||
import {
|
||||
externalChainExplorerUrl,
|
||||
fetchCwRegistry,
|
||||
flattenCwRegistry,
|
||||
type WrappedTransportTokenRow,
|
||||
} from '@/services/api/cwRegistry'
|
||||
import {
|
||||
fetchTokenMappingPairs,
|
||||
getMeshDestinationChainIds,
|
||||
resolveMeshFromHub,
|
||||
resolveTokenMapping,
|
||||
type TokenMappingPair,
|
||||
} from '@/services/api/tokenMapping'
|
||||
import {
|
||||
buildChainNameMap,
|
||||
buildMeshCounterpartRows,
|
||||
inferMeshSeed,
|
||||
type MeshCounterpartRow,
|
||||
} from '@/utils/meshCounterparts'
|
||||
import { fetchPolygonMapper, type PolygonMapperResponse } from '@/services/api/polygonMapper'
|
||||
import { resolveEnsAddress, isEnsName } from '@/utils/ens'
|
||||
import { resolveAddressFromRegistryEns } from '@/utils/web3IdentityRegistry'
|
||||
import { searchCuratedPools, type PoolSearchRow } from '@/utils/poolSearch'
|
||||
import { fetchPoolRegistry, type CuratedPoolRegistryEntry } from '@/services/api/liquidityPositions'
|
||||
|
||||
type SearchFilterMode = 'all' | 'gru' | 'x402' | 'wrapped'
|
||||
|
||||
@@ -55,6 +83,12 @@ export default function SearchPage({
|
||||
const [savedQueries, setSavedQueries] = useState<string[]>([])
|
||||
const [filterMode, setFilterMode] = useState<SearchFilterMode>('all')
|
||||
const [tokenMarkets, setTokenMarkets] = useState<Record<string, TokenAggregationTokenSnapshot>>({})
|
||||
const [wrappedRegistryRows, setWrappedRegistryRows] = useState<WrappedTransportTokenRow[]>([])
|
||||
const [mappingPairs, setMappingPairs] = useState<TokenMappingPair[]>([])
|
||||
const [meshRows, setMeshRows] = useState<MeshCounterpartRow[]>([])
|
||||
const [meshLoading, setMeshLoading] = useState(false)
|
||||
const [polygonMapper, setPolygonMapper] = useState<PolygonMapperResponse | null>(null)
|
||||
const [poolRegistryRows, setPoolRegistryRows] = useState<CuratedPoolRegistryEntry[]>([])
|
||||
|
||||
const runSearch = async (rawQuery: string) => {
|
||||
const trimmedQuery = rawQuery.trim()
|
||||
@@ -109,6 +143,49 @@ export default function SearchPage({
|
||||
}
|
||||
}, [initialCuratedTokens])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void fetchCwRegistry()
|
||||
.then((chains) => {
|
||||
if (!active) return
|
||||
setWrappedRegistryRows(flattenCwRegistry(chains))
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setWrappedRegistryRows([])
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void fetchPoolRegistry(138)
|
||||
.then((report) => {
|
||||
if (active) setPoolRegistryRows(report?.pools ?? [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setPoolRegistryRows([])
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void fetchTokenMappingPairs()
|
||||
.then((pairs) => {
|
||||
if (active) setMappingPairs(pairs)
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setMappingPairs([])
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
@@ -150,14 +227,34 @@ export default function SearchPage({
|
||||
return next
|
||||
})
|
||||
|
||||
const wrappedMatches = searchWrappedTransportTokens(trimmedQuery, wrappedRegistryRows)
|
||||
const meshSeed = inferMeshSeed(trimmedQuery, curatedTokens, wrappedRegistryRows)
|
||||
|
||||
const tokenTarget = inferTokenSearchTarget(trimmedQuery, curatedTokens)
|
||||
if (tokenTarget) {
|
||||
if (tokenTarget && !meshSeed) {
|
||||
void router.push(tokenTarget.href)
|
||||
return
|
||||
}
|
||||
|
||||
if (wrappedMatches.length === 1 && wrappedMatches[0].chainId === 138) {
|
||||
void router.push(`/tokens/${wrappedMatches[0].address}`)
|
||||
return
|
||||
}
|
||||
|
||||
const ensRegistryTarget = inferEnsSearchTarget(trimmedQuery)
|
||||
if (ensRegistryTarget) {
|
||||
void router.push(ensRegistryTarget.href)
|
||||
return
|
||||
}
|
||||
|
||||
const resolvedEns = await resolveEnsAddress(trimmedQuery)
|
||||
if (resolvedEns) {
|
||||
void router.push(`/addresses/${resolvedEns}`)
|
||||
return
|
||||
}
|
||||
|
||||
const directTarget = inferDirectSearchTarget(trimmedQuery)
|
||||
if (directTarget) {
|
||||
if (directTarget && !shouldDeferChain138AddressJump(trimmedQuery, wrappedMatches)) {
|
||||
void router.push(directTarget.href)
|
||||
return
|
||||
}
|
||||
@@ -175,7 +272,25 @@ export default function SearchPage({
|
||||
|
||||
const trimmedQuery = query.trim()
|
||||
const tokenTarget = inferTokenSearchTarget(trimmedQuery, curatedTokens)
|
||||
const meshSeed = useMemo(
|
||||
() => inferMeshSeed(trimmedQuery, curatedTokens, wrappedRegistryRows),
|
||||
[curatedTokens, trimmedQuery, wrappedRegistryRows],
|
||||
)
|
||||
const chainNameById = useMemo(() => buildChainNameMap(wrappedRegistryRows), [wrappedRegistryRows])
|
||||
const poolMatches = useMemo(
|
||||
() => searchCuratedPools(trimmedQuery, poolRegistryRows),
|
||||
[poolRegistryRows, trimmedQuery],
|
||||
)
|
||||
const wrappedMatches = useMemo(
|
||||
() => searchWrappedTransportTokens(trimmedQuery, wrappedRegistryRows),
|
||||
[trimmedQuery, wrappedRegistryRows],
|
||||
)
|
||||
const filteredWrappedMatches = useMemo(() => {
|
||||
if (filterMode === 'all' || filterMode === 'wrapped') return wrappedMatches
|
||||
return []
|
||||
}, [filterMode, wrappedMatches])
|
||||
const directTarget = inferDirectSearchTarget(trimmedQuery)
|
||||
const deferAddressJump = shouldDeferChain138AddressJump(trimmedQuery, wrappedMatches)
|
||||
const results = useMemo(
|
||||
() => normalizeExplorerSearchResults(trimmedQuery, rawResults, curatedTokens),
|
||||
[curatedTokens, rawResults, trimmedQuery],
|
||||
@@ -197,6 +312,15 @@ export default function SearchPage({
|
||||
blocks: filteredResults.filter((result) => result.type === 'block'),
|
||||
other: filteredResults.filter((result) => !['token', 'address', 'transaction', 'block'].includes(result.type)),
|
||||
}), [filteredResults])
|
||||
const hasSupplementalMatches = useMemo(
|
||||
() =>
|
||||
meshRows.length > 0 ||
|
||||
poolMatches.length > 0 ||
|
||||
filteredWrappedMatches.length > 0 ||
|
||||
curatedSuggestions.length > 0 ||
|
||||
Boolean(meshSeed),
|
||||
[curatedSuggestions.length, filteredWrappedMatches.length, meshRows.length, meshSeed, poolMatches.length],
|
||||
)
|
||||
const resultSections = [
|
||||
{ label: 'Tokens', items: groupedResults.tokens },
|
||||
{ label: 'Addresses', items: groupedResults.addresses },
|
||||
@@ -205,6 +329,68 @@ export default function SearchPage({
|
||||
{ label: 'Other', items: groupedResults.other },
|
||||
]
|
||||
|
||||
useEffect(() => {
|
||||
if (!meshSeed || !hasSearched) {
|
||||
setMeshRows([])
|
||||
setMeshLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
setMeshLoading(true)
|
||||
|
||||
void (async () => {
|
||||
let hubAddress = meshSeed.hubAddress
|
||||
if (meshSeed.needsHubResolve && meshSeed.sourceChainId != null && meshSeed.sourceAddress) {
|
||||
const toHub = await resolveTokenMapping(meshSeed.sourceChainId, meshSeed.hubChainId, meshSeed.sourceAddress)
|
||||
hubAddress = toHub?.addressOnTarget ?? hubAddress
|
||||
}
|
||||
|
||||
if (!hubAddress || !/^0x[a-fA-F0-9]{40}$/.test(hubAddress)) {
|
||||
if (active) {
|
||||
setMeshRows([])
|
||||
setMeshLoading(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const destinationChainIds = getMeshDestinationChainIds(mappingPairs, meshSeed.hubChainId)
|
||||
const resolvedByChain = await resolveMeshFromHub(meshSeed.hubChainId, hubAddress, destinationChainIds)
|
||||
const rows = buildMeshCounterpartRows(meshSeed, resolvedByChain, wrappedRegistryRows, chainNameById)
|
||||
if (active) {
|
||||
setMeshRows(rows)
|
||||
setMeshLoading(false)
|
||||
}
|
||||
})().catch(() => {
|
||||
if (active) {
|
||||
setMeshRows([])
|
||||
setMeshLoading(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [chainNameById, hasSearched, mappingPairs, meshSeed, wrappedRegistryRows])
|
||||
|
||||
useEffect(() => {
|
||||
if (!meshSeed || !hasSearched) {
|
||||
setPolygonMapper(null)
|
||||
return
|
||||
}
|
||||
let active = true
|
||||
void fetchPolygonMapper(trimmedQuery)
|
||||
.then((report) => {
|
||||
if (active) setPolygonMapper(report)
|
||||
})
|
||||
.catch(() => {
|
||||
if (active) setPolygonMapper(null)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [hasSearched, meshSeed, trimmedQuery])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
@@ -233,11 +419,13 @@ export default function SearchPage({
|
||||
title="Search"
|
||||
description={
|
||||
mode === 'guided'
|
||||
? 'Search by address, transaction hash, block number, or token symbol. Direct identifiers can jump straight into detail pages, while broader terms fall back to indexed search.'
|
||||
: 'Search address, tx hash, block, or token symbol. Direct identifiers jump straight to detail pages.'
|
||||
? 'Search by address, transaction hash, block number, token symbol, or cW*/c* wrapped transport symbols. Mesh counterparts resolve across chains via token-mapping; Chain 138 uses indexed search.'
|
||||
: 'Search tx / addr / block / token / mesh counterparts (token-mapping + cW registry).'
|
||||
}
|
||||
actions={[
|
||||
{ href: '/tokens', label: 'Token shortcuts' },
|
||||
{ href: '/protocols', label: 'Protocol contracts' },
|
||||
{ href: '/pools', label: 'Pool registry' },
|
||||
{ href: '/addresses', label: 'Browse addresses' },
|
||||
{ href: '/watchlist', label: 'Open watchlist' },
|
||||
]}
|
||||
@@ -264,19 +452,25 @@ export default function SearchPage({
|
||||
|
||||
{!loading && error && (
|
||||
<Card className="mb-6">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">{error}</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/tokens" className="text-primary-600 hover:underline">
|
||||
Try token shortcuts →
|
||||
</Link>
|
||||
<Link href="/addresses" className="text-primary-600 hover:underline">
|
||||
Browse addresses →
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{meshRows.length > 0 || poolMatches.length > 0 || filteredWrappedMatches.length > 0
|
||||
? 'Indexed Blockscout search is temporarily unavailable. Mesh, pool registry, and wrapped-token matches below are still available.'
|
||||
: error}
|
||||
</p>
|
||||
{meshRows.length === 0 && poolMatches.length === 0 && filteredWrappedMatches.length === 0 ? (
|
||||
<div className="mt-4 flex flex-wrap gap-3 text-sm">
|
||||
<Link href="/tokens" className="text-primary-600 hover:underline">
|
||||
Try token shortcuts →
|
||||
</Link>
|
||||
<Link href="/addresses" className="text-primary-600 hover:underline">
|
||||
Browse addresses →
|
||||
</Link>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && tokenTarget && (
|
||||
{!loading && tokenTarget && !meshSeed && (
|
||||
<Card className="mb-6" title="Direct Token Match">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{mode === 'guided'
|
||||
@@ -291,7 +485,213 @@ export default function SearchPage({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && !tokenTarget && directTarget && (
|
||||
{!loading && !tokenTarget && directTarget && deferAddressJump && (
|
||||
<Card title="Cross-network wrapped token match">
|
||||
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
This address is a wrapped transport token on another network — not a Chain 138 address. Open it on the native explorer for that chain.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{wrappedMatches
|
||||
.filter((row) => row.matchReason === 'exact address')
|
||||
.map((row) => {
|
||||
const href = externalChainExplorerUrl(row.chainId, row.address)
|
||||
return (
|
||||
<div key={`${row.chainId}-${row.address}`} className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-700">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EntityBadge label={row.symbol} tone="info" />
|
||||
<EntityBadge label={`chain ${row.chainId}`} tone="neutral" />
|
||||
<EntityBadge label={row.chainName} tone="warning" />
|
||||
<EntityBadge label={row.matchReason} tone="success" className="normal-case tracking-normal" />
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Address address={row.address} truncate showCopy />
|
||||
</div>
|
||||
{href ? (
|
||||
<a
|
||||
href={href}
|
||||
target={row.chainId === 138 ? undefined : '_blank'}
|
||||
rel={row.chainId === 138 ? undefined : 'noopener noreferrer'}
|
||||
className="mt-2 inline-block text-sm font-medium text-primary-600 hover:underline"
|
||||
>
|
||||
{row.chainId === 138 ? 'Open on Chain 138 →' : `Open on ${row.chainName} explorer →`}
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && poolMatches.length > 0 && hasSearched && (
|
||||
<Card title="Curated liquidity pools">
|
||||
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Matches from the DODO PMM + UniV2 pool registry on Chain 138. LP receipt tokens stay chain-local — bridge
|
||||
underlying c*/cW* instead.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{poolMatches.slice(0, 12).map((row) => (
|
||||
<Link
|
||||
key={`${row.poolAddress}-${row.matchReason}`}
|
||||
href={`/pools/${row.poolAddress}`}
|
||||
className="block rounded-xl border border-gray-200 px-4 py-3 hover:border-primary-300 dark:border-gray-700"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EntityBadge label={row.pairLabel} tone="info" />
|
||||
<EntityBadge label={row.venue.replace('_', ' ')} tone="neutral" />
|
||||
<EntityBadge label={row.matchReason} tone="success" className="normal-case tracking-normal" />
|
||||
</div>
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Pool <Address address={row.poolAddress} truncate showCopy={false} />
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && meshSeed && (meshLoading || meshRows.length > 0) && (
|
||||
<Card title="Mesh counterparts">
|
||||
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
{mode === 'guided'
|
||||
? `Resolved ${meshSeed.hubSymbol} hub on Chain 138 and mapped ${meshSeed.wrappedSymbol} addresses across configured transport pairs via token-mapping.`
|
||||
: `${meshSeed.hubSymbol} ↔ ${meshSeed.wrappedSymbol} mesh (${meshSeed.matchReason}).`}
|
||||
</p>
|
||||
{polygonMapper?.officialPolygonTokenList?.upstreamRepo ? (
|
||||
<p className="mb-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
Polygon (137) rows align with{' '}
|
||||
<a
|
||||
href={polygonMapper.officialPolygonTokenList.submissionDoc || polygonMapper.officialPolygonTokenList.upstreamRepo}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary-600 hover:underline"
|
||||
>
|
||||
official Polygon Token Mapper
|
||||
</a>
|
||||
{polygonMapper.match?.polygonAddress
|
||||
? ` — matched ${polygonMapper.match.wrappedSymbol} at ${polygonMapper.match.polygonAddress.slice(0, 10)}…`
|
||||
: ''}
|
||||
.
|
||||
</p>
|
||||
) : null}
|
||||
{meshLoading ? (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Resolving mesh addresses…</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{meshRows.map((row) => {
|
||||
const href = externalChainExplorerUrl(row.chainId, row.address)
|
||||
const content = (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EntityBadge label={row.symbol} tone="info" />
|
||||
<EntityBadge label={`chain ${row.chainId}`} tone="neutral" />
|
||||
<EntityBadge label={row.chainName} tone="warning" />
|
||||
<EntityBadge
|
||||
label={row.role === 'hub' ? 'hub (138)' : row.mappedVia === 'token-mapping' ? 'mapped' : 'registry'}
|
||||
tone={row.role === 'hub' ? 'success' : 'info'}
|
||||
className="normal-case tracking-normal"
|
||||
/>
|
||||
{row.chainId === 137 && polygonMapper ? (
|
||||
<EntityBadge label="Polygon mapper" tone="warning" className="normal-case tracking-normal" />
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Address address={row.address} truncate showCopy />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
if (!href) {
|
||||
return (
|
||||
<div key={`mesh-${row.chainId}-${row.address}`} className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-700">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (row.chainId === 138) {
|
||||
return (
|
||||
<Link
|
||||
key={`mesh-${row.chainId}-${row.address}`}
|
||||
href={href}
|
||||
className="block rounded-xl border border-gray-200 px-4 py-3 text-primary-600 hover:border-primary-300 dark:border-gray-700"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a
|
||||
key={`mesh-${row.chainId}-${row.address}`}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block rounded-xl border border-gray-200 px-4 py-3 text-primary-600 hover:border-primary-300 dark:border-gray-700"
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && filteredWrappedMatches.length > 0 && meshRows.length === 0 && !meshLoading && (
|
||||
<Card title="Wrapped transport tokens (cross-network)">
|
||||
<p className="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Matches from the live cW* registry across public networks. Chain 138 tokens open here; other chains link to native explorers.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{filteredWrappedMatches.slice(0, 24).map((row) => {
|
||||
const href = externalChainExplorerUrl(row.chainId, row.address)
|
||||
const content = (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<EntityBadge label={row.symbol} tone="info" />
|
||||
<EntityBadge label={`chain ${row.chainId}`} tone="neutral" />
|
||||
<EntityBadge label={row.chainName} tone="warning" />
|
||||
<EntityBadge label="cW registry" tone="success" />
|
||||
<EntityBadge label={row.matchReason} tone="info" className="normal-case tracking-normal" />
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<Address address={row.address} truncate showCopy />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
if (!href) {
|
||||
return (
|
||||
<div key={`${row.chainId}-${row.address}`} className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-700">
|
||||
{content}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (row.chainId === 138) {
|
||||
return (
|
||||
<Link
|
||||
key={`${row.chainId}-${row.address}`}
|
||||
href={href}
|
||||
className="block rounded-xl border border-gray-200 px-4 py-3 text-primary-600 hover:border-primary-300 dark:border-gray-700"
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<a
|
||||
key={`${row.chainId}-${row.address}`}
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block rounded-xl border border-gray-200 px-4 py-3 text-primary-600 hover:border-primary-300 dark:border-gray-700"
|
||||
>
|
||||
{content}
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && !tokenTarget && directTarget && !deferAddressJump && (
|
||||
<Card className="mb-6" title="Direct Match">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{mode === 'guided'
|
||||
@@ -325,7 +725,7 @@ export default function SearchPage({
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<Card title="Search Results">
|
||||
<Card title="Indexed search results">
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{([
|
||||
@@ -433,7 +833,17 @@ export default function SearchPage({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && hasSearched && !error && filteredResults.length === 0 && (
|
||||
{!loading && hasSearched && hasSupplementalMatches && filteredResults.length === 0 && !error ? (
|
||||
<Card className="mb-6" title="Registry matches">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Blockscout indexed search returned no hits for{' '}
|
||||
<span className="font-medium text-gray-900 dark:text-white">{trimmedQuery}</span>, but curated mesh,
|
||||
pool, or wrapped-token registry matches are shown below.
|
||||
</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{!loading && hasSearched && !error && filteredResults.length === 0 && !hasSupplementalMatches && (
|
||||
<Card title="No Results Found">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
No explorer results matched <span className="font-medium text-gray-900 dark:text-white">{trimmedQuery}</span>
|
||||
@@ -496,6 +906,23 @@ export const getServerSideProps: GetServerSideProps<SearchPageProps> = async (co
|
||||
const initialQuery = typeof context.query.q === 'string' ? context.query.q.trim() : ''
|
||||
const { tokens: initialCuratedTokens } = await fetchTokenListForSurface('catalog', 138)
|
||||
|
||||
const tokenTarget = initialQuery ? inferTokenSearchTarget(initialQuery, initialCuratedTokens) : null
|
||||
if (tokenTarget) {
|
||||
return { redirect: { destination: tokenTarget.href, permanent: false } }
|
||||
}
|
||||
|
||||
const directTarget = initialQuery ? inferDirectSearchTarget(initialQuery) : null
|
||||
if (directTarget?.kind === 'address') {
|
||||
return { redirect: { destination: directTarget.href, permanent: false } }
|
||||
}
|
||||
|
||||
if (initialQuery && isEnsName(initialQuery)) {
|
||||
const resolved = await resolveEnsAddress(initialQuery)
|
||||
if (resolved) {
|
||||
return { redirect: { destination: `/addresses/${resolved}`, permanent: false } }
|
||||
}
|
||||
}
|
||||
|
||||
const shouldFetchSearch =
|
||||
Boolean(initialQuery) &&
|
||||
!inferTokenSearchTarget(initialQuery, initialCuratedTokens) &&
|
||||
|
||||
@@ -15,7 +15,10 @@ import GruStandardsCard from '@/components/common/GruStandardsCard'
|
||||
import TokenSigningSurfaceCard from '@/components/common/TokenSigningSurfaceCard'
|
||||
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
|
||||
import PaginationControls from '@/components/common/PaginationControls'
|
||||
import SectionTabs, { type SectionTab } from '@/components/common/SectionTabs'
|
||||
import SectionTabs, { sectionTabPanelProps, type SectionTab } from '@/components/common/SectionTabs'
|
||||
import { useDetailTabQuery } from '@/utils/useDetailTabQuery'
|
||||
|
||||
const TOKEN_DETAIL_TABS_ID = 'token-detail'
|
||||
import { formatTokenAmount, formatTimestamp } from '@/utils/format'
|
||||
import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru'
|
||||
import { getGruExplorerMetadata } from '@/services/api/gruExplorerData'
|
||||
@@ -57,7 +60,6 @@ export default function TokenDetailPage() {
|
||||
const [gruProfile, setGruProfile] = useState<GruStandardsProfile | null>(null)
|
||||
const [contractProfile, setContractProfile] = useState<ContractProfile | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'intelligence' | 'standards' | 'holders' | 'transfers' | 'liquidity'>('intelligence')
|
||||
const [holderPage, setHolderPage] = useState(1)
|
||||
const [transferPage, setTransferPage] = useState(1)
|
||||
const [poolPage, setPoolPage] = useState(1)
|
||||
@@ -194,13 +196,21 @@ export default function TokenDetailPage() {
|
||||
() => getGruExplorerMetadata({ address: token?.address || address, symbol: token?.symbol }),
|
||||
[address, token?.address, token?.symbol],
|
||||
)
|
||||
const tabs: SectionTab<typeof activeTab>[] = [
|
||||
{ id: 'intelligence', label: 'Intelligence' },
|
||||
...(gruProfile || gruExplorerMetadata ? [{ id: 'standards' as const, label: 'Standards' }] : []),
|
||||
{ id: 'holders', label: 'Holders', count: holders.length },
|
||||
{ id: 'transfers', label: 'Transfers', count: transfers.length },
|
||||
{ id: 'liquidity', label: 'Liquidity', count: pools.length },
|
||||
]
|
||||
const tabs = useMemo<SectionTab<'intelligence' | 'standards' | 'holders' | 'transfers' | 'liquidity'>[]>(
|
||||
() => [
|
||||
{ id: 'intelligence', label: 'Intelligence' },
|
||||
...(gruProfile || gruExplorerMetadata ? [{ id: 'standards' as const, label: 'Standards' }] : []),
|
||||
{ id: 'holders', label: 'Holders', count: holders.length },
|
||||
{ id: 'transfers', label: 'Transfers', count: transfers.length },
|
||||
{ id: 'liquidity', label: 'Liquidity', count: pools.length },
|
||||
],
|
||||
[gruExplorerMetadata, gruProfile, holders.length, pools.length, transfers.length],
|
||||
)
|
||||
const tokenTabIds = useMemo(
|
||||
() => tabs.map((tab) => tab.id),
|
||||
[tabs],
|
||||
)
|
||||
const { activeTab, setActiveTab } = useDetailTabQuery(tokenTabIds, 'intelligence', address)
|
||||
const holderPageCount = Math.max(1, Math.ceil(holders.length / pageSize))
|
||||
const transferPageCount = Math.max(1, Math.ceil(transfers.length / pageSize))
|
||||
const poolPageCount = Math.max(1, Math.ceil(pools.length / pageSize))
|
||||
@@ -218,7 +228,6 @@ export default function TokenDetailPage() {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab('intelligence')
|
||||
setHolderPage(1)
|
||||
setTransferPage(1)
|
||||
setPoolPage(1)
|
||||
@@ -407,9 +416,17 @@ export default function TokenDetailPage() {
|
||||
|
||||
<TokenSigningSurfaceCard address={token.address} contractProfile={contractProfile} />
|
||||
|
||||
<SectionTabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="mb-6" />
|
||||
<SectionTabs
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onChange={setActiveTab}
|
||||
className="mb-6"
|
||||
idPrefix={TOKEN_DETAIL_TABS_ID}
|
||||
ariaLabel="Token details"
|
||||
/>
|
||||
|
||||
{activeTab === 'intelligence' ? <Card title="Token Intelligence">
|
||||
<div {...sectionTabPanelProps(TOKEN_DETAIL_TABS_ID, 'intelligence', activeTab)}>
|
||||
<Card title="Token Intelligence">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Market Context</div>
|
||||
@@ -454,11 +471,13 @@ export default function TokenDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card> : null}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{activeTab === 'standards' && gruProfile ? <GruStandardsCard profile={gruProfile} /> : null}
|
||||
<div {...sectionTabPanelProps(TOKEN_DETAIL_TABS_ID, 'standards', activeTab)}>
|
||||
{gruProfile ? <GruStandardsCard profile={gruProfile} /> : null}
|
||||
|
||||
{activeTab === 'standards' && gruExplorerMetadata ? (
|
||||
{gruExplorerMetadata ? (
|
||||
<Card title="x402 And ISO-20022 Posture">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
@@ -504,7 +523,7 @@ export default function TokenDetailPage() {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{activeTab === 'standards' && gruExplorerMetadata && gruExplorerMetadata.otherNetworks.length > 0 ? (
|
||||
{gruExplorerMetadata && gruExplorerMetadata.otherNetworks.length > 0 ? (
|
||||
<Card title="Other Networks">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
@@ -536,8 +555,10 @@ export default function TokenDetailPage() {
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{activeTab === 'holders' ? <Card title="Top Holders">
|
||||
<div {...sectionTabPanelProps(TOKEN_DETAIL_TABS_ID, 'holders', activeTab)}>
|
||||
<Card title="Top Holders">
|
||||
<Table
|
||||
layout="tabular"
|
||||
columns={holderColumns}
|
||||
@@ -546,9 +567,11 @@ export default function TokenDetailPage() {
|
||||
keyExtractor={(holder) => holder.address}
|
||||
/>
|
||||
<PaginationControls page={holderPage} pageCount={holderPageCount} onPageChange={setHolderPage} label="Holders" />
|
||||
</Card> : null}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{activeTab === 'transfers' ? <Card title="Recent Transfers">
|
||||
<div {...sectionTabPanelProps(TOKEN_DETAIL_TABS_ID, 'transfers', activeTab)}>
|
||||
<Card title="Recent Transfers">
|
||||
{gruExplorerMetadata ? (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>
|
||||
@@ -569,9 +592,11 @@ export default function TokenDetailPage() {
|
||||
keyExtractor={(transfer) => `${transfer.transaction_hash}-${transfer.value}-${transfer.from_address}`}
|
||||
/>
|
||||
<PaginationControls page={transferPage} pageCount={transferPageCount} onPageChange={setTransferPage} label="Transfers" />
|
||||
</Card> : null}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{activeTab === 'liquidity' ? <Card title="Related Liquidity">
|
||||
<div {...sectionTabPanelProps(TOKEN_DETAIL_TABS_ID, 'liquidity', activeTab)}>
|
||||
<Card title="Related Liquidity">
|
||||
<Table
|
||||
columns={poolColumns}
|
||||
data={pagedPools}
|
||||
@@ -579,7 +604,8 @@ export default function TokenDetailPage() {
|
||||
keyExtractor={(pool) => pool.address}
|
||||
/>
|
||||
<PaginationControls page={poolPage} pageCount={poolPageCount} onPageChange={setPoolPage} label="Pools" />
|
||||
</Card> : null}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -17,7 +17,10 @@ import EntityBadge from '@/components/common/EntityBadge'
|
||||
import PostureBadge from '@/components/common/PostureBadge'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import PaginationControls from '@/components/common/PaginationControls'
|
||||
import SectionTabs, { type SectionTab } from '@/components/common/SectionTabs'
|
||||
import SectionTabs, { sectionTabPanelProps, type SectionTab } from '@/components/common/SectionTabs'
|
||||
import { useDetailTabQuery } from '@/utils/useDetailTabQuery'
|
||||
|
||||
const TRANSACTION_DETAIL_TABS_ID = 'transaction-detail'
|
||||
import { getGruCatalogPosture } from '@/services/api/gruCatalog'
|
||||
import { assessTransactionCompliance } from '@/utils/transactionCompliance'
|
||||
import MainnetAttestationPanel from '@/components/checkpoint/MainnetAttestationPanel'
|
||||
@@ -79,7 +82,6 @@ export default function TransactionDetailPage() {
|
||||
const [historicalNativePrice, setHistoricalNativePrice] = useState<TokenAggregationHistoricalPriceSnapshot | null>(null)
|
||||
const [checkpointAttestation, setCheckpointAttestation] = useState<CheckpointTxAttestationSnapshot | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'evidence' | 'details' | 'transfers' | 'internal' | 'raw'>('evidence')
|
||||
const [transferPage, setTransferPage] = useState(1)
|
||||
const [internalPage, setInternalPage] = useState(1)
|
||||
const pageSize = 8
|
||||
@@ -354,13 +356,21 @@ export default function TransactionDetailPage() {
|
||||
tokenTransfers: transaction.token_transfers || [],
|
||||
})
|
||||
: null
|
||||
const tabs: SectionTab<typeof activeTab>[] = [
|
||||
{ id: 'evidence', label: 'Evidence' },
|
||||
{ id: 'details', label: 'Details' },
|
||||
{ id: 'transfers', label: 'Transfers', count: tokenTransferCount },
|
||||
{ id: 'internal', label: 'Internal', count: internalCallCount },
|
||||
...(transaction?.input_data ? [{ id: 'raw' as const, label: 'Raw input' }] : []),
|
||||
]
|
||||
const tabs = useMemo<SectionTab<'evidence' | 'details' | 'transfers' | 'internal' | 'raw'>[]>(
|
||||
() => [
|
||||
{ id: 'evidence', label: 'Evidence' },
|
||||
{ id: 'details', label: 'Details' },
|
||||
{ id: 'transfers', label: 'Transfers', count: tokenTransferCount },
|
||||
{ id: 'internal', label: 'Internal', count: internalCallCount },
|
||||
...(transaction?.input_data ? [{ id: 'raw' as const, label: 'Raw input' }] : []),
|
||||
],
|
||||
[internalCallCount, tokenTransferCount, transaction?.input_data],
|
||||
)
|
||||
const transactionTabIds = useMemo(
|
||||
() => tabs.map((tab) => tab.id),
|
||||
[tabs],
|
||||
)
|
||||
const { activeTab, setActiveTab } = useDetailTabQuery(transactionTabIds, 'evidence', hash)
|
||||
const transferPageCount = Math.max(1, Math.ceil((transaction?.token_transfers?.length || 0) / pageSize))
|
||||
const internalPageCount = Math.max(1, Math.ceil(internalCalls.length / pageSize))
|
||||
const pagedTokenTransfers = useMemo(
|
||||
@@ -373,7 +383,6 @@ export default function TransactionDetailPage() {
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab('evidence')
|
||||
setTransferPage(1)
|
||||
setInternalPage(1)
|
||||
}, [hash])
|
||||
@@ -513,9 +522,17 @@ export default function TransactionDetailPage() {
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<SectionTabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="mb-6" />
|
||||
<SectionTabs
|
||||
tabs={tabs}
|
||||
activeTab={activeTab}
|
||||
onChange={setActiveTab}
|
||||
className="mb-6"
|
||||
idPrefix={TRANSACTION_DETAIL_TABS_ID}
|
||||
ariaLabel="Transaction details"
|
||||
/>
|
||||
|
||||
{activeTab === 'evidence' && complianceAssessment ? (
|
||||
<div {...sectionTabPanelProps(TRANSACTION_DETAIL_TABS_ID, 'evidence', activeTab)}>
|
||||
{complianceAssessment ? (
|
||||
<Card title="Transaction Evidence Matrix">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
@@ -541,9 +558,17 @@ export default function TransactionDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
) : (
|
||||
<Card title="Transaction Evidence Matrix">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Compliance evidence is unavailable for this transaction.
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeTab === 'details' ? <Card title="Transaction Information">
|
||||
<div {...sectionTabPanelProps(TRANSACTION_DETAIL_TABS_ID, 'details', activeTab)}>
|
||||
<Card title="Transaction Information">
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Hash">
|
||||
<Address address={transaction.hash} />
|
||||
@@ -607,10 +632,10 @@ export default function TransactionDetailPage() {
|
||||
</DetailRow>
|
||||
)}
|
||||
</dl>
|
||||
</Card> : null}
|
||||
</Card>
|
||||
|
||||
{activeTab === 'details' && transaction.decoded_input && transaction.decoded_input.parameters.length > 0 && (
|
||||
<Card title="Decoded Input">
|
||||
{transaction.decoded_input && transaction.decoded_input.parameters.length > 0 && (
|
||||
<Card title="Decoded Input" className="mt-6">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{transaction.decoded_input.method_call || transaction.decoded_input.method_id || 'Decoded call'}
|
||||
@@ -637,8 +662,10 @@ export default function TransactionDetailPage() {
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{activeTab === 'transfers' ? <Card title="Token Transfers">
|
||||
<div {...sectionTabPanelProps(TRANSACTION_DETAIL_TABS_ID, 'transfers', activeTab)}>
|
||||
<Card title="Token Transfers">
|
||||
<Table
|
||||
columns={tokenTransferColumns}
|
||||
data={pagedTokenTransfers}
|
||||
@@ -646,9 +673,11 @@ export default function TransactionDetailPage() {
|
||||
keyExtractor={(transfer) => `${transfer.token_address}-${transfer.from_address}-${transfer.to_address}-${transfer.amount}`}
|
||||
/>
|
||||
<PaginationControls page={transferPage} pageCount={transferPageCount} onPageChange={setTransferPage} label="Token transfers" />
|
||||
</Card> : null}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{activeTab === 'internal' ? <Card title="Internal Transactions">
|
||||
<div {...sectionTabPanelProps(TRANSACTION_DETAIL_TABS_ID, 'internal', activeTab)}>
|
||||
<Card title="Internal Transactions">
|
||||
<Table
|
||||
columns={internalCallColumns}
|
||||
data={pagedInternalCalls}
|
||||
@@ -656,15 +685,18 @@ export default function TransactionDetailPage() {
|
||||
keyExtractor={(call) => `${call.from_address}-${call.to_address || call.contract_address || 'unknown'}-${call.value}-${call.type || 'call'}`}
|
||||
/>
|
||||
<PaginationControls page={internalPage} pageCount={internalPageCount} onPageChange={setInternalPage} label="Internal calls" />
|
||||
</Card> : null}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{activeTab === 'raw' && transaction.input_data && (
|
||||
{transaction.input_data ? (
|
||||
<div {...sectionTabPanelProps(TRANSACTION_DETAIL_TABS_ID, 'raw', activeTab)}>
|
||||
<Card title="Raw Input Data">
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-all rounded-lg bg-gray-50 p-4 text-xs text-gray-800 dark:bg-gray-950 dark:text-gray-200">
|
||||
{transaction.input_data}
|
||||
</pre>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,8 @@ import { normalizeTransaction } from '@/services/api/blockscout'
|
||||
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||
import PaginationControls from '@/components/common/PaginationControls'
|
||||
import { useListPageQuery } from '@/utils/useListPageQuery'
|
||||
import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats'
|
||||
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
||||
@@ -51,7 +53,7 @@ export default function TransactionsPage({
|
||||
const pageSize = 20
|
||||
const [transactions, setTransactions] = useState<Transaction[]>(initialTransactions)
|
||||
const [loading, setLoading] = useState(initialTransactions.length === 0)
|
||||
const [page, setPage] = useState(1)
|
||||
const { page, setPage } = useListPageQuery()
|
||||
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||
const activityContext = useMemo(
|
||||
() =>
|
||||
@@ -252,25 +254,17 @@ export default function TransactionsPage({
|
||||
/>
|
||||
)}
|
||||
|
||||
{showPagination && (
|
||||
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
||||
<button
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={loading || page === 1}
|
||||
className="rounded bg-gray-200 px-4 py-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<span className="px-3 py-2 text-sm sm:px-4">Page {page}</span>
|
||||
<button
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
disabled={loading || !canGoNext}
|
||||
className="rounded bg-gray-200 px-4 py-2 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{showPagination ? (
|
||||
<PaginationControls
|
||||
page={page}
|
||||
onPageChange={setPage}
|
||||
label="Transactions"
|
||||
ariaLabel="Transactions pagination"
|
||||
disabled={loading}
|
||||
hasNextPage={canGoNext}
|
||||
className="mt-6"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="mt-8 grid gap-4 lg:grid-cols-2">
|
||||
<Card title="Next Steps">
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function WalletRoutePage(props: WalletRoutePageProps) {
|
||||
export const getServerSideProps: GetServerSideProps<WalletRoutePageProps> = async () => {
|
||||
const [networksResult, tokenListResult, capabilitiesResult] = await Promise.all([
|
||||
fetchPublicJsonWithMeta<NetworksCatalog>('/api/config/networks').catch(() => null),
|
||||
fetchPublicJsonWithMeta<TokenListCatalog>('/api/v1/report/token-list?chainId=138').catch(() => null),
|
||||
fetchPublicJsonWithMeta<TokenListCatalog>('/api/v1/report/token-list?chainId=138&wallet=1').catch(() => null),
|
||||
fetchPublicJsonWithMeta<CapabilitiesCatalog>('/api/config/capabilities').catch(() => null),
|
||||
])
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface AddressTokenBalance {
|
||||
value: string
|
||||
holder_count?: number
|
||||
total_supply?: string
|
||||
exchange_rate?: string | number | null
|
||||
}
|
||||
|
||||
export interface AddressTokenTransfer {
|
||||
|
||||
@@ -67,6 +67,7 @@ export interface BlockscoutTokenRef {
|
||||
type?: string | null
|
||||
total_supply?: string | null
|
||||
holders?: StringLike
|
||||
exchange_rate?: StringLike
|
||||
}
|
||||
|
||||
export interface BlockscoutTokenTransfer {
|
||||
@@ -310,6 +311,7 @@ export function normalizeAddressTokenBalance(raw: {
|
||||
value: raw.value || '0',
|
||||
holder_count: raw.token?.holders != null ? toNumber(raw.token.holders) : undefined,
|
||||
total_supply: raw.token?.total_supply || undefined,
|
||||
exchange_rate: raw.token?.exchange_rate ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
89
frontend/src/services/api/cwRegistry.ts
Normal file
89
frontend/src/services/api/cwRegistry.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import { getTokenAggregationApiBase } from '@/services/api/tokenAggregation'
|
||||
|
||||
export interface WrappedTransportTokenRow {
|
||||
chainId: number
|
||||
chainName: string
|
||||
symbol: string
|
||||
address: string
|
||||
assetClass?: string
|
||||
familyKey?: string
|
||||
}
|
||||
|
||||
export interface CwRegistryChainRow {
|
||||
chainId: number
|
||||
chainIdText?: string
|
||||
name: string
|
||||
tokens: Array<{
|
||||
symbol: string
|
||||
address: string
|
||||
assetClass?: string
|
||||
familyKey?: string
|
||||
}>
|
||||
}
|
||||
|
||||
export interface CwRegistryResponse {
|
||||
generatedAt: string
|
||||
source: string
|
||||
complete: boolean
|
||||
chains: CwRegistryChainRow[]
|
||||
}
|
||||
|
||||
export function externalChainExplorerUrl(chainId: number, address: string): string | undefined {
|
||||
const normalized = address.trim()
|
||||
if (!/^0x[a-fA-F0-9]{40}$/.test(normalized)) return undefined
|
||||
switch (chainId) {
|
||||
case 138:
|
||||
return `/tokens/${normalized}`
|
||||
case 1:
|
||||
return `https://etherscan.io/token/${normalized}`
|
||||
case 56:
|
||||
return `https://bscscan.com/token/${normalized}`
|
||||
case 137:
|
||||
return `https://polygonscan.com/token/${normalized}`
|
||||
case 100:
|
||||
return `https://gnosisscan.io/token/${normalized}`
|
||||
case 10:
|
||||
return `https://optimistic.etherscan.io/token/${normalized}`
|
||||
case 42161:
|
||||
return `https://arbiscan.io/token/${normalized}`
|
||||
case 8453:
|
||||
return `https://basescan.org/token/${normalized}`
|
||||
case 43114:
|
||||
return `https://snowtrace.io/token/${normalized}`
|
||||
case 25:
|
||||
return `https://cronoscan.com/token/${normalized}`
|
||||
case 42220:
|
||||
return `https://celoscan.io/token/${normalized}`
|
||||
case 1111:
|
||||
return `https://scan.wemix.com/token/${normalized}`
|
||||
case 651940:
|
||||
return `https://alltra.global/address/${normalized}`
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchCwRegistry(): Promise<CwRegistryChainRow[]> {
|
||||
const response = await fetch(`${getTokenAggregationApiBase()}/report/cw-registry`, { cache: 'no-store' })
|
||||
if (!response.ok) return []
|
||||
const body = (await response.json()) as CwRegistryResponse
|
||||
return Array.isArray(body.chains) ? body.chains : []
|
||||
}
|
||||
|
||||
export function flattenCwRegistry(chains: CwRegistryChainRow[]): WrappedTransportTokenRow[] {
|
||||
const rows: WrappedTransportTokenRow[] = []
|
||||
for (const chain of chains) {
|
||||
for (const token of chain.tokens ?? []) {
|
||||
if (!token.address?.startsWith('0x')) continue
|
||||
rows.push({
|
||||
chainId: chain.chainId,
|
||||
chainName: chain.name || `Chain ${chain.chainId}`,
|
||||
symbol: token.symbol,
|
||||
address: token.address,
|
||||
assetClass: token.assetClass,
|
||||
familyKey: token.familyKey,
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
88
frontend/src/services/api/liquidityPositions.ts
Normal file
88
frontend/src/services/api/liquidityPositions.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { getTokenAggregationApiBase } from '@/services/api/tokenAggregation'
|
||||
|
||||
export interface CuratedPoolRegistryEntry {
|
||||
chainId: number
|
||||
chainName: string
|
||||
poolAddress: string
|
||||
lpTokenAddress: string
|
||||
lpTokenType: 'dodo_dvm' | 'univ2_pair'
|
||||
venue: 'dodo_pmm' | 'uniswap_v2'
|
||||
baseSymbol: string
|
||||
quoteSymbol: string
|
||||
baseAddress: string
|
||||
quoteAddress: string
|
||||
status?: string
|
||||
role?: string
|
||||
publicRoutingEnabled?: boolean
|
||||
feeBps?: number
|
||||
totalLiquidityUsd?: number
|
||||
reserve0?: string
|
||||
reserve1?: string
|
||||
reserve0Usd?: number
|
||||
reserve1Usd?: number
|
||||
liquiditySource?: string
|
||||
liquidityAsOf?: string
|
||||
integrationStack?: string
|
||||
}
|
||||
|
||||
export interface PoolRegistryResponse {
|
||||
generatedAt: string
|
||||
source: string
|
||||
complete: boolean
|
||||
count: number
|
||||
pools: CuratedPoolRegistryEntry[]
|
||||
}
|
||||
|
||||
export interface LpPositionRow {
|
||||
chainId: number
|
||||
poolAddress: string
|
||||
lpTokenAddress: string
|
||||
lpTokenType: 'dodo_dvm' | 'univ2_pair'
|
||||
venue: string
|
||||
pairLabel: string
|
||||
shareBalanceRaw: string
|
||||
shareBalanceUnits: string
|
||||
shareOfPool: number
|
||||
estimatedUsd: number | null
|
||||
baseSymbol: string
|
||||
quoteSymbol: string
|
||||
status: 'active' | 'zero'
|
||||
}
|
||||
|
||||
export interface LpPositionsResponse {
|
||||
generatedAt: string
|
||||
chainId: number
|
||||
address: string
|
||||
rpcUsed: boolean
|
||||
poolsScanned: number
|
||||
activePositions: number
|
||||
totalEstimatedUsd: number | null
|
||||
positions: LpPositionRow[]
|
||||
notes: string[]
|
||||
}
|
||||
|
||||
const base = () => getTokenAggregationApiBase()
|
||||
|
||||
export async function fetchPoolRegistry(chainId?: number): Promise<PoolRegistryResponse | null> {
|
||||
const query = chainId ? `?chainId=${chainId}` : ''
|
||||
const response = await fetch(`${base()}/report/pool-registry${query}`, { cache: 'no-store' })
|
||||
if (!response.ok) return null
|
||||
return (await response.json()) as PoolRegistryResponse
|
||||
}
|
||||
|
||||
export async function fetchLpPositions(params: {
|
||||
chainId: number
|
||||
address: string
|
||||
hintAddresses?: string[]
|
||||
}): Promise<LpPositionsResponse | null> {
|
||||
const search = new URLSearchParams({
|
||||
chainId: String(params.chainId),
|
||||
address: params.address,
|
||||
})
|
||||
if (params.hintAddresses?.length) {
|
||||
search.set('hintAddresses', params.hintAddresses.join(','))
|
||||
}
|
||||
const response = await fetch(`${base()}/report/lp-positions?${search.toString()}`, { cache: 'no-store' })
|
||||
if (!response.ok) return null
|
||||
return (await response.json()) as LpPositionsResponse
|
||||
}
|
||||
@@ -20,6 +20,10 @@ export async function getNativeAssetMarketSafe(
|
||||
chainId: number,
|
||||
): Promise<{ ok: boolean; data: TokenAggregationTokenSnapshot | null }> {
|
||||
const descriptor = getNativeAssetDescriptor(chainId)
|
||||
const batch = await tokenAggregationApi.getTokensByAddressSafe(chainId, [descriptor.pricingAddress])
|
||||
if (batch.ok && batch.data[0]) {
|
||||
return { ok: true, data: batch.data[0] }
|
||||
}
|
||||
return tokenAggregationApi.getTokenSafe(chainId, descriptor.pricingAddress)
|
||||
}
|
||||
|
||||
|
||||
68
frontend/src/services/api/officialProtocols.ts
Normal file
68
frontend/src/services/api/officialProtocols.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { getTokenAggregationApiBase } from '@/services/api/tokenAggregation'
|
||||
|
||||
export interface OfficialProtocolContractRow {
|
||||
name: string
|
||||
address: string
|
||||
inventoryKey?: string
|
||||
minCodeSize?: number
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export type ContractVerificationStatus = 'verified' | 'below_min' | 'no_code' | 'error'
|
||||
|
||||
export interface ContractVerificationRow {
|
||||
name: string
|
||||
address: string
|
||||
codeSize: number
|
||||
minCodeSize?: number
|
||||
status: ContractVerificationStatus
|
||||
}
|
||||
|
||||
export interface ProtocolVerificationReport {
|
||||
verifiedAt: string
|
||||
chainId: number
|
||||
rpcUsed: boolean
|
||||
allVerified: boolean
|
||||
contracts: ContractVerificationRow[]
|
||||
}
|
||||
|
||||
export interface OfficialProtocolRow {
|
||||
id: string
|
||||
classification?: string
|
||||
requiredForProduction?: boolean
|
||||
upstreamRepo?: string
|
||||
upstreamSubmodule?: string
|
||||
deployScripts?: string[]
|
||||
verifyScripts?: string[]
|
||||
deployMethod?: string
|
||||
integrationNotes?: string
|
||||
contracts: OfficialProtocolContractRow[]
|
||||
}
|
||||
|
||||
export interface OfficialProtocolsResponse {
|
||||
generatedAt: string
|
||||
source: string
|
||||
chainId: number
|
||||
chainName?: string
|
||||
policyDoc?: string
|
||||
guardrails: string[]
|
||||
forbiddenProductionPatterns: Array<{ id: string; description: string }>
|
||||
count: number
|
||||
protocols: OfficialProtocolRow[]
|
||||
protocol?: OfficialProtocolRow
|
||||
verification?: ProtocolVerificationReport
|
||||
schemaVersion?: string
|
||||
updated?: string
|
||||
}
|
||||
|
||||
export async function fetchOfficialProtocols(protocolId?: string): Promise<OfficialProtocolsResponse | null> {
|
||||
const base = `${getTokenAggregationApiBase()}/report/official-protocols`
|
||||
const url = protocolId ? `${base}/${encodeURIComponent(protocolId)}` : base
|
||||
try {
|
||||
const response = await fetch(url, { cache: 'no-store' })
|
||||
if (!response.ok) return null
|
||||
return (await response.json()) as OfficialProtocolsResponse
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
59
frontend/src/services/api/polygonMapper.ts
Normal file
59
frontend/src/services/api/polygonMapper.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { getTokenAggregationApiBase } from '@/services/api/tokenAggregation'
|
||||
|
||||
export interface PolygonMeshTokenRow {
|
||||
hubSymbol: string
|
||||
wrappedSymbol: string
|
||||
decimals?: number
|
||||
hub138Address?: string | null
|
||||
mainnetRootAddress?: string
|
||||
polygonAddress?: string
|
||||
polygonExplorerUrl?: string | null
|
||||
hub138ExplorerUrl?: string | null
|
||||
mainnetExplorerUrl?: string | null
|
||||
}
|
||||
|
||||
export interface PolygonMapperResponse {
|
||||
chainId: number
|
||||
hubChainId: number
|
||||
meshTokens: PolygonMeshTokenRow[]
|
||||
vipBridge?: {
|
||||
bridgeAddress?: string
|
||||
austdPolygon?: string
|
||||
ausdtAll?: string
|
||||
}
|
||||
officialPolygonTokenList?: {
|
||||
mappedApi?: string
|
||||
upstreamRepo?: string
|
||||
submissionDoc?: string
|
||||
}
|
||||
match?: PolygonMeshTokenRow
|
||||
query?: string
|
||||
}
|
||||
|
||||
export async function fetchPolygonMapper(query?: string): Promise<PolygonMapperResponse | null> {
|
||||
const params = query?.trim() ? `?q=${encodeURIComponent(query.trim())}` : ''
|
||||
try {
|
||||
const response = await fetch(`${getTokenAggregationApiBase()}/report/polygon-mapper${params}`, {
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (!response.ok) return null
|
||||
return (await response.json()) as PolygonMapperResponse
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function polygonMapperRowForChain(
|
||||
mapper: PolygonMapperResponse | null,
|
||||
chainId: number,
|
||||
address: string,
|
||||
): PolygonMeshTokenRow | undefined {
|
||||
if (!mapper || chainId !== 137) return undefined
|
||||
const lower = address.toLowerCase()
|
||||
return mapper.meshTokens?.find(
|
||||
(row) =>
|
||||
row.polygonAddress?.toLowerCase() === lower ||
|
||||
row.hub138Address?.toLowerCase() === lower ||
|
||||
row.mainnetRootAddress?.toLowerCase() === lower,
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,8 @@ export interface TokenAggregationMarketSnapshot {
|
||||
volume24h?: number
|
||||
liquidityUsd?: number
|
||||
lastUpdated?: string | null
|
||||
pricingKind?: 'spot' | 'lp-share'
|
||||
isCanonicalClone?: boolean
|
||||
}
|
||||
|
||||
export interface TokenAggregationTokenSnapshot {
|
||||
@@ -68,6 +70,23 @@ export interface CheckpointTxAttestationSnapshot {
|
||||
source?: string
|
||||
}
|
||||
|
||||
interface RawTokenMarketBatchResponse {
|
||||
chainId?: number
|
||||
snapshots?: Array<{
|
||||
address?: string
|
||||
symbol?: string | null
|
||||
name?: string | null
|
||||
decimals?: number | string | null
|
||||
priceUsd?: number | string | null
|
||||
liquidityUsd?: number | string | null
|
||||
volume24h?: number | string | null
|
||||
lastUpdated?: string | null
|
||||
source?: string | null
|
||||
pricingKind?: 'spot' | 'lp-share' | null
|
||||
isCanonicalClone?: boolean | null
|
||||
}>
|
||||
}
|
||||
|
||||
interface RawTokenAggregationTokenResponse {
|
||||
token?: {
|
||||
chainId?: number | string | null
|
||||
@@ -200,6 +219,10 @@ function getTokenAggregationBase(): string {
|
||||
return `${resolveExplorerApiBase()}/token-aggregation/api/v1`
|
||||
}
|
||||
|
||||
export function getTokenAggregationApiBase(): string {
|
||||
return getTokenAggregationBase()
|
||||
}
|
||||
|
||||
export const tokenAggregationApi = {
|
||||
getTokenSafe: async (chainId: number, address: string): Promise<{ ok: boolean; data: TokenAggregationTokenSnapshot | null }> => {
|
||||
try {
|
||||
@@ -223,11 +246,62 @@ export const tokenAggregationApi = {
|
||||
return { ok: true, data: [] }
|
||||
}
|
||||
|
||||
const results = await Promise.all(uniqueAddresses.map((address) => tokenAggregationApi.getTokenSafe(chainId, address)))
|
||||
const data = results
|
||||
.filter((result): result is { ok: true; data: TokenAggregationTokenSnapshot | null } => result.ok)
|
||||
.map((result) => result.data)
|
||||
.filter((snapshot): snapshot is TokenAggregationTokenSnapshot => Boolean(snapshot?.address))
|
||||
try {
|
||||
const batchResponse = await fetch(`${getTokenAggregationBase()}/tokens/market-batch`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
body: JSON.stringify({ chainId, addresses: uniqueAddresses }),
|
||||
})
|
||||
if (batchResponse.ok) {
|
||||
const batchRaw = (await batchResponse.json()) as RawTokenMarketBatchResponse
|
||||
const data: TokenAggregationTokenSnapshot[] = (batchRaw.snapshots || [])
|
||||
.flatMap((snapshot) => {
|
||||
if (!snapshot.address) return []
|
||||
const priceUsd = toNumber(snapshot.priceUsd)
|
||||
const liquidityUsd = toNumber(snapshot.liquidityUsd)
|
||||
const entry: TokenAggregationTokenSnapshot = {
|
||||
chainId,
|
||||
address: snapshot.address,
|
||||
name: snapshot.name || undefined,
|
||||
symbol: snapshot.symbol || undefined,
|
||||
decimals: toNumber(snapshot.decimals),
|
||||
market: priceUsd != null || liquidityUsd != null || snapshot.pricingKind
|
||||
? {
|
||||
priceUsd,
|
||||
liquidityUsd,
|
||||
volume24h: toNumber(snapshot.volume24h),
|
||||
lastUpdated: snapshot.lastUpdated || null,
|
||||
pricingKind: snapshot.pricingKind || undefined,
|
||||
isCanonicalClone: snapshot.isCanonicalClone ?? undefined,
|
||||
}
|
||||
: null,
|
||||
}
|
||||
return [entry]
|
||||
})
|
||||
|
||||
if (data.length > 0) {
|
||||
return { ok: true, data }
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to per-token requests below.
|
||||
}
|
||||
|
||||
const chunkSize = 24
|
||||
const data: TokenAggregationTokenSnapshot[] = []
|
||||
for (let offset = 0; offset < uniqueAddresses.length; offset += chunkSize) {
|
||||
const chunk = uniqueAddresses.slice(offset, offset + chunkSize)
|
||||
const results = await Promise.all(chunk.map((address) => tokenAggregationApi.getTokenSafe(chainId, address)))
|
||||
for (const result of results) {
|
||||
if (result.ok && result.data?.address) {
|
||||
data.push(result.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: data.length > 0, data }
|
||||
},
|
||||
|
||||
92
frontend/src/services/api/tokenMapping.ts
Normal file
92
frontend/src/services/api/tokenMapping.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { getTokenAggregationApiBase } from '@/services/api/tokenAggregation'
|
||||
|
||||
export interface TokenMappingPair {
|
||||
fromChainId: number
|
||||
toChainId: number
|
||||
notes?: string
|
||||
}
|
||||
|
||||
export interface TokenMappingPairsResponse {
|
||||
pairs: TokenMappingPair[]
|
||||
}
|
||||
|
||||
export interface TokenMappingResolveResponse {
|
||||
fromChainId: number
|
||||
toChainId: number
|
||||
addressOnSource: string
|
||||
addressOnTarget: string | null
|
||||
activeTransportEligible?: boolean
|
||||
gruTransportRuntimeReady?: boolean
|
||||
gruTransportFamilyKey?: string | null
|
||||
gruTransportCanonicalToken?: Record<string, unknown> | null
|
||||
}
|
||||
|
||||
export async function fetchTokenMappingPairs(): Promise<TokenMappingPair[]> {
|
||||
try {
|
||||
const response = await fetch(`${getTokenAggregationApiBase()}/token-mapping/pairs`, { cache: 'no-store' })
|
||||
if (!response.ok) return []
|
||||
const body = (await response.json()) as TokenMappingPairsResponse
|
||||
return Array.isArray(body.pairs) ? body.pairs : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveTokenMapping(
|
||||
fromChainId: number,
|
||||
toChainId: number,
|
||||
address: string,
|
||||
): Promise<TokenMappingResolveResponse | null> {
|
||||
const normalized = address.trim()
|
||||
if (!/^0x[a-fA-F0-9]{40}$/.test(normalized)) return null
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
fromChain: String(fromChainId),
|
||||
toChain: String(toChainId),
|
||||
address: normalized,
|
||||
})
|
||||
const response = await fetch(`${getTokenAggregationApiBase()}/token-mapping/resolve?${params}`, {
|
||||
cache: 'no-store',
|
||||
})
|
||||
if (!response.ok) return null
|
||||
return (await response.json()) as TokenMappingResolveResponse
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getMeshDestinationChainIds(pairs: TokenMappingPair[], hubChainId = 138): number[] {
|
||||
const ids = new Set<number>([hubChainId])
|
||||
for (const pair of pairs) {
|
||||
if (pair.fromChainId === hubChainId) ids.add(pair.toChainId)
|
||||
if (pair.toChainId === hubChainId) ids.add(pair.fromChainId)
|
||||
}
|
||||
return [...ids].sort((left, right) => {
|
||||
if (left === hubChainId) return -1
|
||||
if (right === hubChainId) return 1
|
||||
return left - right
|
||||
})
|
||||
}
|
||||
|
||||
export async function resolveMeshFromHub(
|
||||
hubChainId: number,
|
||||
hubAddress: string,
|
||||
destinationChainIds: number[],
|
||||
): Promise<Map<number, string>> {
|
||||
const byChain = new Map<number, string>()
|
||||
byChain.set(hubChainId, hubAddress)
|
||||
|
||||
await Promise.all(
|
||||
destinationChainIds
|
||||
.filter((chainId) => chainId !== hubChainId)
|
||||
.map(async (chainId) => {
|
||||
const resolved = await resolveTokenMapping(hubChainId, chainId, hubAddress)
|
||||
const target = resolved?.addressOnTarget?.trim()
|
||||
if (target && /^0x[a-fA-F0-9]{40}$/.test(target) && target !== '0x0000000000000000000000000000000000000000') {
|
||||
byChain.set(chainId, target)
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
return byChain
|
||||
}
|
||||
@@ -75,3 +75,8 @@ export function getActiveWalletConnectSessionId(): string | null {
|
||||
const session = activeProvider?.session
|
||||
return typeof session?.topic === 'string' && session.topic ? session.topic : null
|
||||
}
|
||||
|
||||
/** Use for EIP-747 / EIP-3085 when no injected `window.ethereum` (e.g. desktop without extension). */
|
||||
export function getActiveWalletConnectProvider(): Awaited<ReturnType<typeof createEthereumProvider>> | null {
|
||||
return activeProvider
|
||||
}
|
||||
|
||||
147
frontend/src/utils/ens.ts
Normal file
147
frontend/src/utils/ens.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Mainnet ENS resolution (forward + reverse) via JSON-RPC.
|
||||
* Chain 138 has no native ENS — resolution always uses Ethereum mainnet.
|
||||
*/
|
||||
|
||||
import { keccak256 } from 'js-sha3'
|
||||
|
||||
const MAINNET_RPC =
|
||||
(typeof process !== 'undefined' && process.env.NEXT_PUBLIC_MAINNET_RPC_URL) ||
|
||||
'https://ethereum.publicnode.com'
|
||||
|
||||
const ENS_REGISTRY = '0x00000000000C2E074eC69A0dFb2997BA6C7d2e1e'
|
||||
const ZERO_ADDRESS = '0x0000000000000000000000000000000000000000'
|
||||
|
||||
const CACHE_TTL_MS = 60 * 60 * 1000
|
||||
const forwardCache = new Map<string, { value: string | null; at: number }>()
|
||||
const reverseCache = new Map<string, { value: string | null; at: number }>()
|
||||
|
||||
function readCache<T>(map: Map<string, { value: T; at: number }>, key: string): T | undefined {
|
||||
const hit = map.get(key)
|
||||
if (!hit) return undefined
|
||||
if (Date.now() - hit.at > CACHE_TTL_MS) {
|
||||
map.delete(key)
|
||||
return undefined
|
||||
}
|
||||
return hit.value
|
||||
}
|
||||
|
||||
function writeCache<T>(map: Map<string, { value: T; at: number }>, key: string, value: T) {
|
||||
map.set(key, { value, at: Date.now() })
|
||||
}
|
||||
|
||||
async function rpcCall(method: string, params: unknown[]): Promise<string> {
|
||||
const response = await fetch(MAINNET_RPC, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
||||
})
|
||||
const data = await response.json()
|
||||
if (data.error) throw new Error(data.error.message || 'RPC error')
|
||||
return data.result as string
|
||||
}
|
||||
|
||||
function strip0x(hex: string): string {
|
||||
return hex.startsWith('0x') ? hex.slice(2) : hex
|
||||
}
|
||||
|
||||
function pad32(hex: string): string {
|
||||
return hex.padStart(64, '0')
|
||||
}
|
||||
|
||||
// Minimal namehash (ENS EIP-137)
|
||||
function namehash(name: string): string {
|
||||
let node = new Uint8Array(32)
|
||||
if (name) {
|
||||
const labels = name.split('.').reverse()
|
||||
for (const label of labels) {
|
||||
const labelHash = keccak256.arrayBuffer(new TextEncoder().encode(label))
|
||||
const combined = new Uint8Array(64)
|
||||
combined.set(node)
|
||||
combined.set(new Uint8Array(labelHash), 32)
|
||||
node = new Uint8Array(keccak256.arrayBuffer(combined))
|
||||
}
|
||||
}
|
||||
return `0x${Array.from(node, (b) => b.toString(16).padStart(2, '0')).join('')}`
|
||||
}
|
||||
|
||||
async function getResolver(node: string): Promise<string | null> {
|
||||
const data = `0x0178b8bf${pad32(strip0x(node))}`
|
||||
const result = await rpcCall('eth_call', [{ to: ENS_REGISTRY, data }, 'latest'])
|
||||
if (!result || result === '0x' || result.length < 66) return null
|
||||
const resolver = `0x${result.slice(-40)}`
|
||||
if (resolver.toLowerCase() === ZERO_ADDRESS) return null
|
||||
return resolver
|
||||
}
|
||||
|
||||
export async function resolveEnsAddress(name: string): Promise<string | null> {
|
||||
const normalized = name.trim().toLowerCase()
|
||||
if (!normalized.endsWith('.eth')) return null
|
||||
const cached = readCache(forwardCache, normalized)
|
||||
if (cached !== undefined) return cached
|
||||
|
||||
try {
|
||||
const node = namehash(normalized)
|
||||
const resolver = await getResolver(node)
|
||||
if (!resolver) {
|
||||
writeCache(forwardCache, normalized, null)
|
||||
return null
|
||||
}
|
||||
const data = `0x3b3b57de${pad32(strip0x(node))}`
|
||||
const result = await rpcCall('eth_call', [{ to: resolver, data }, 'latest'])
|
||||
if (!result || result.length < 66) {
|
||||
writeCache(forwardCache, normalized, null)
|
||||
return null
|
||||
}
|
||||
const address = `0x${result.slice(-40)}`
|
||||
if (address.toLowerCase() === ZERO_ADDRESS) {
|
||||
writeCache(forwardCache, normalized, null)
|
||||
return null
|
||||
}
|
||||
writeCache(forwardCache, normalized, address)
|
||||
return address
|
||||
} catch {
|
||||
writeCache(forwardCache, normalized, null)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveEnsName(address: string): Promise<string | null> {
|
||||
const normalized = address.trim().toLowerCase()
|
||||
if (!/^0x[a-f0-9]{40}$/.test(normalized)) return null
|
||||
const cached = readCache(reverseCache, normalized)
|
||||
if (cached !== undefined) return cached
|
||||
|
||||
try {
|
||||
const reverseNode = namehash(`${normalized.slice(2)}.addr.reverse`)
|
||||
const resolver = await getResolver(reverseNode)
|
||||
if (!resolver) {
|
||||
writeCache(reverseCache, normalized, null)
|
||||
return null
|
||||
}
|
||||
const data = `0x691f3431${pad32(strip0x(reverseNode))}`
|
||||
const result = await rpcCall('eth_call', [{ to: resolver, data }, 'latest'])
|
||||
if (!result || result === '0x' || result.length <= 2) {
|
||||
writeCache(reverseCache, normalized, null)
|
||||
return null
|
||||
}
|
||||
const hex = strip0x(result)
|
||||
const offset = parseInt(hex.slice(0, 64), 16) * 2
|
||||
const len = parseInt(hex.slice(offset, offset + 64), 16)
|
||||
const nameHex = hex.slice(offset + 64, offset + 64 + len * 2)
|
||||
const name = new TextDecoder().decode(
|
||||
new Uint8Array(nameHex.match(/.{1,2}/g)?.map((b) => parseInt(b, 16)) || []),
|
||||
)
|
||||
const value = name || null
|
||||
writeCache(reverseCache, normalized, value)
|
||||
return value
|
||||
} catch {
|
||||
writeCache(reverseCache, normalized, null)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function isEnsName(query: string): boolean {
|
||||
const trimmed = query.trim().toLowerCase()
|
||||
return trimmed.endsWith('.eth') && trimmed.length > 4 && !trimmed.includes(' ')
|
||||
}
|
||||
86
frontend/src/utils/meshCounterparts.test.ts
Normal file
86
frontend/src/utils/meshCounterparts.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildMeshCounterpartRows, inferMeshSeed } from './meshCounterparts'
|
||||
|
||||
const wrappedRows = [
|
||||
{
|
||||
chainId: 1,
|
||||
chainName: 'Ethereum Mainnet',
|
||||
symbol: 'cWUSDC',
|
||||
address: '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a',
|
||||
},
|
||||
{
|
||||
chainId: 56,
|
||||
chainName: 'BNB Chain',
|
||||
symbol: 'cWUSDC',
|
||||
address: '0x5355148C4740fcc3D7a96F05EdD89AB14851206b',
|
||||
},
|
||||
{
|
||||
chainId: 138,
|
||||
chainName: 'Chain 138',
|
||||
symbol: 'cUSDC',
|
||||
address: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
|
||||
},
|
||||
]
|
||||
|
||||
const curated = [
|
||||
{
|
||||
chainId: 138,
|
||||
symbol: 'cUSDC',
|
||||
address: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
|
||||
},
|
||||
]
|
||||
|
||||
describe('inferMeshSeed', () => {
|
||||
it('detects hub symbol from curated tokens', () => {
|
||||
expect(inferMeshSeed('cUSDC', curated, wrappedRows)).toMatchObject({
|
||||
hubSymbol: 'cUSDC',
|
||||
wrappedSymbol: 'cWUSDC',
|
||||
hubAddress: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
|
||||
needsHubResolve: false,
|
||||
matchReason: 'hub symbol',
|
||||
})
|
||||
})
|
||||
|
||||
it('detects wrapped symbol and resolves hub from registry', () => {
|
||||
expect(inferMeshSeed('cWUSDC', curated, wrappedRows)).toMatchObject({
|
||||
hubSymbol: 'cUSDC',
|
||||
wrappedSymbol: 'cWUSDC',
|
||||
hubAddress: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
|
||||
matchReason: 'wrapped symbol',
|
||||
})
|
||||
})
|
||||
|
||||
it('detects off-home wrapped address and flags hub resolve when hub unknown', () => {
|
||||
const seed = inferMeshSeed('0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a', [], wrappedRows)
|
||||
expect(seed).toMatchObject({
|
||||
hubSymbol: 'cUSDC',
|
||||
sourceChainId: 1,
|
||||
sourceAddress: '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a',
|
||||
needsHubResolve: false,
|
||||
hubAddress: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
|
||||
})
|
||||
})
|
||||
|
||||
it('returns null for unrelated queries', () => {
|
||||
expect(inferMeshSeed('random', curated, wrappedRows)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildMeshCounterpartRows', () => {
|
||||
it('merges token-mapping resolves with registry rows', () => {
|
||||
const seed = inferMeshSeed('cUSDC', curated, wrappedRows)!
|
||||
const resolved = new Map<number, string>([
|
||||
[138, seed.hubAddress!],
|
||||
[1, '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a'],
|
||||
])
|
||||
const chainNames = new Map<number, string>([
|
||||
[138, 'Chain 138'],
|
||||
[1, 'Ethereum Mainnet'],
|
||||
[56, 'BNB Chain'],
|
||||
])
|
||||
const rows = buildMeshCounterpartRows(seed, resolved, wrappedRows, chainNames)
|
||||
expect(rows.map((row) => row.chainId)).toEqual([138, 1, 56])
|
||||
expect(rows[0].role).toBe('hub')
|
||||
expect(rows.filter((row) => row.chainId === 56)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
205
frontend/src/utils/meshCounterparts.ts
Normal file
205
frontend/src/utils/meshCounterparts.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import type { SearchTokenHint, WrappedTransportRegistryRow } from '@/utils/search'
|
||||
|
||||
const HUB_CHAIN_ID = 138
|
||||
|
||||
/** c* on hub ↔ cW* on destination — mirrors config/token-mapping-multichain.json */
|
||||
export const HUB_TO_WRAPPED_SYMBOL: Record<string, string> = {
|
||||
cUSDT: 'cWUSDT',
|
||||
cUSDC: 'cWUSDC',
|
||||
cEURC: 'cWEURC',
|
||||
cEURT: 'cWEURT',
|
||||
cGBPC: 'cWGBPC',
|
||||
cGBPT: 'cWGBPT',
|
||||
cAUDC: 'cWAUDC',
|
||||
cJPYC: 'cWJPYC',
|
||||
cCHFC: 'cWCHFC',
|
||||
cCADC: 'cWCADC',
|
||||
cXAUC: 'cWXAUC',
|
||||
cXAUT: 'cWXAUT',
|
||||
cBTC: 'cWBTC',
|
||||
}
|
||||
|
||||
const WRAPPED_TO_HUB_SYMBOL = Object.fromEntries(
|
||||
Object.entries(HUB_TO_WRAPPED_SYMBOL).map(([hub, wrapped]) => [wrapped.toLowerCase(), hub]),
|
||||
) as Record<string, string>
|
||||
|
||||
const addressPattern = /^0x[a-f0-9]{40}$/i
|
||||
|
||||
export interface MeshSeed {
|
||||
hubChainId: number
|
||||
hubAddress?: string
|
||||
hubSymbol: string
|
||||
wrappedSymbol: string
|
||||
sourceChainId?: number
|
||||
sourceAddress?: string
|
||||
matchReason: string
|
||||
needsHubResolve: boolean
|
||||
}
|
||||
|
||||
export interface MeshCounterpartRow {
|
||||
chainId: number
|
||||
chainName: string
|
||||
symbol: string
|
||||
address: string
|
||||
role: 'hub' | 'wrapped' | 'registry'
|
||||
mappedVia: 'token-mapping' | 'cw-registry' | 'curated'
|
||||
}
|
||||
|
||||
function hubSymbolFor(rawSymbol: string): string | null {
|
||||
const sym = rawSymbol.trim()
|
||||
if (!sym) return null
|
||||
const lower = sym.toLowerCase()
|
||||
if (HUB_TO_WRAPPED_SYMBOL[sym]) return sym
|
||||
return WRAPPED_TO_HUB_SYMBOL[lower] ?? null
|
||||
}
|
||||
|
||||
function wrappedSymbolFor(hubSymbol: string): string {
|
||||
return HUB_TO_WRAPPED_SYMBOL[hubSymbol] ?? hubSymbol
|
||||
}
|
||||
|
||||
function findHubInCurated(hubSymbol: string, curatedTokens: SearchTokenHint[]): SearchTokenHint | undefined {
|
||||
const lower = hubSymbol.toLowerCase()
|
||||
return curatedTokens.find(
|
||||
(token) => token.chainId === HUB_CHAIN_ID && token.symbol?.toLowerCase() === lower && token.address,
|
||||
)
|
||||
}
|
||||
|
||||
function findHubInRegistry(hubSymbol: string, rows: WrappedTransportRegistryRow[]): WrappedTransportRegistryRow | undefined {
|
||||
const lower = hubSymbol.toLowerCase()
|
||||
return rows.find((row) => row.chainId === HUB_CHAIN_ID && row.symbol.toLowerCase() === lower)
|
||||
}
|
||||
|
||||
export function inferMeshSeed(
|
||||
query: string,
|
||||
curatedTokens: SearchTokenHint[] = [],
|
||||
wrappedRows: WrappedTransportRegistryRow[] = [],
|
||||
): MeshSeed | null {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
if (addressPattern.test(trimmed)) {
|
||||
const lower = trimmed.toLowerCase()
|
||||
|
||||
const curated = curatedTokens.find(
|
||||
(token) => token.chainId === HUB_CHAIN_ID && token.address?.toLowerCase() === lower,
|
||||
)
|
||||
if (curated?.address && curated.symbol) {
|
||||
const hubSymbol = hubSymbolFor(curated.symbol) ?? curated.symbol
|
||||
return {
|
||||
hubChainId: HUB_CHAIN_ID,
|
||||
hubAddress: curated.address,
|
||||
hubSymbol,
|
||||
wrappedSymbol: wrappedSymbolFor(hubSymbol),
|
||||
sourceChainId: HUB_CHAIN_ID,
|
||||
sourceAddress: curated.address,
|
||||
matchReason: 'curated hub address',
|
||||
needsHubResolve: false,
|
||||
}
|
||||
}
|
||||
|
||||
const registryHit = wrappedRows.find((row) => row.address.toLowerCase() === lower)
|
||||
if (registryHit) {
|
||||
const hubSym = hubSymbolFor(registryHit.symbol)
|
||||
if (!hubSym) return null
|
||||
const hubRow = findHubInRegistry(hubSym, wrappedRows) ?? findHubInCurated(hubSym, curatedTokens)
|
||||
if (registryHit.chainId === HUB_CHAIN_ID) {
|
||||
return {
|
||||
hubChainId: HUB_CHAIN_ID,
|
||||
hubAddress: registryHit.address,
|
||||
hubSymbol: hubSym,
|
||||
wrappedSymbol: wrappedSymbolFor(hubSym),
|
||||
sourceChainId: HUB_CHAIN_ID,
|
||||
sourceAddress: registryHit.address,
|
||||
matchReason: 'registry hub address',
|
||||
needsHubResolve: false,
|
||||
}
|
||||
}
|
||||
return {
|
||||
hubChainId: HUB_CHAIN_ID,
|
||||
hubAddress: hubRow?.address,
|
||||
hubSymbol: hubSym,
|
||||
wrappedSymbol: wrappedSymbolFor(hubSym),
|
||||
sourceChainId: registryHit.chainId,
|
||||
sourceAddress: registryHit.address,
|
||||
matchReason: 'registry wrapped address',
|
||||
needsHubResolve: !hubRow?.address,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const hubFromSymbol = hubSymbolFor(trimmed)
|
||||
if (hubFromSymbol) {
|
||||
const hubRow = findHubInRegistry(hubFromSymbol, wrappedRows) ?? findHubInCurated(hubFromSymbol, curatedTokens)
|
||||
return {
|
||||
hubChainId: HUB_CHAIN_ID,
|
||||
hubAddress: hubRow?.address,
|
||||
hubSymbol: hubFromSymbol,
|
||||
wrappedSymbol: wrappedSymbolFor(hubFromSymbol),
|
||||
matchReason: trimmed.toLowerCase() === hubFromSymbol.toLowerCase() ? 'hub symbol' : 'wrapped symbol',
|
||||
needsHubResolve: !hubRow?.address,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function buildMeshCounterpartRows(
|
||||
seed: MeshSeed,
|
||||
resolvedByChain: Map<number, string>,
|
||||
wrappedRows: WrappedTransportRegistryRow[],
|
||||
chainNameById: Map<number, string>,
|
||||
): MeshCounterpartRow[] {
|
||||
const rows: MeshCounterpartRow[] = []
|
||||
const seen = new Set<string>()
|
||||
|
||||
const push = (row: MeshCounterpartRow) => {
|
||||
const key = `${row.chainId}:${row.address.toLowerCase()}`
|
||||
if (seen.has(key)) return
|
||||
seen.add(key)
|
||||
rows.push(row)
|
||||
}
|
||||
|
||||
for (const [chainId, address] of resolvedByChain.entries()) {
|
||||
if (!/^0x[a-fA-F0-9]{40}$/.test(address)) continue
|
||||
push({
|
||||
chainId,
|
||||
chainName: chainNameById.get(chainId) ?? `Chain ${chainId}`,
|
||||
symbol: chainId === HUB_CHAIN_ID ? seed.hubSymbol : seed.wrappedSymbol,
|
||||
address,
|
||||
role: chainId === HUB_CHAIN_ID ? 'hub' : 'wrapped',
|
||||
mappedVia: 'token-mapping',
|
||||
})
|
||||
}
|
||||
|
||||
for (const row of wrappedRows) {
|
||||
const sym = row.symbol.toLowerCase()
|
||||
if (sym !== seed.hubSymbol.toLowerCase() && sym !== seed.wrappedSymbol.toLowerCase()) continue
|
||||
push({
|
||||
chainId: row.chainId,
|
||||
chainName: row.chainName || chainNameById.get(row.chainId) || `Chain ${row.chainId}`,
|
||||
symbol: row.symbol,
|
||||
address: row.address,
|
||||
role: row.chainId === HUB_CHAIN_ID ? 'hub' : 'registry',
|
||||
mappedVia: 'cw-registry',
|
||||
})
|
||||
}
|
||||
|
||||
return rows.sort((left, right) => {
|
||||
if (left.chainId === HUB_CHAIN_ID) return -1
|
||||
if (right.chainId === HUB_CHAIN_ID) return 1
|
||||
return left.chainId - right.chainId
|
||||
})
|
||||
}
|
||||
|
||||
export function buildChainNameMap(wrappedRows: WrappedTransportRegistryRow[]): Map<number, string> {
|
||||
const map = new Map<number, string>()
|
||||
for (const row of wrappedRows) {
|
||||
if (!map.has(row.chainId) && row.chainName) {
|
||||
map.set(row.chainId, row.chainName)
|
||||
}
|
||||
}
|
||||
map.set(HUB_CHAIN_ID, map.get(HUB_CHAIN_ID) ?? 'Chain 138')
|
||||
return map
|
||||
}
|
||||
30
frontend/src/utils/pagination.test.ts
Normal file
30
frontend/src/utils/pagination.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildPaginationItems } from './pagination'
|
||||
|
||||
describe('buildPaginationItems', () => {
|
||||
it('returns no items for a single page', () => {
|
||||
expect(buildPaginationItems(1, 1)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns all pages when the range is small', () => {
|
||||
expect(buildPaginationItems(2, 5)).toEqual([
|
||||
{ type: 'page', page: 1 },
|
||||
{ type: 'page', page: 2 },
|
||||
{ type: 'page', page: 3 },
|
||||
{ type: 'page', page: 4 },
|
||||
{ type: 'page', page: 5 },
|
||||
])
|
||||
})
|
||||
|
||||
it('inserts ellipsis around the active page on large ranges', () => {
|
||||
expect(buildPaginationItems(10, 20)).toEqual([
|
||||
{ type: 'page', page: 1 },
|
||||
{ type: 'ellipsis', key: 'ellipsis-1-9' },
|
||||
{ type: 'page', page: 9 },
|
||||
{ type: 'page', page: 10 },
|
||||
{ type: 'page', page: 11 },
|
||||
{ type: 'ellipsis', key: 'ellipsis-11-20' },
|
||||
{ type: 'page', page: 20 },
|
||||
])
|
||||
})
|
||||
})
|
||||
43
frontend/src/utils/pagination.ts
Normal file
43
frontend/src/utils/pagination.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export type PaginationItem =
|
||||
| { type: 'page'; page: number }
|
||||
| { type: 'ellipsis'; key: string }
|
||||
|
||||
export function buildPaginationItems(
|
||||
currentPage: number,
|
||||
pageCount: number,
|
||||
siblingCount = 1,
|
||||
): PaginationItem[] {
|
||||
if (pageCount <= 1) {
|
||||
return []
|
||||
}
|
||||
|
||||
const maxPagesWithoutEllipsis = siblingCount * 2 + 4
|
||||
if (pageCount <= maxPagesWithoutEllipsis) {
|
||||
return Array.from({ length: pageCount }, (_, index) => ({
|
||||
type: 'page' as const,
|
||||
page: index + 1,
|
||||
}))
|
||||
}
|
||||
|
||||
const pages = new Set<number>([1, pageCount])
|
||||
|
||||
for (let page = currentPage - siblingCount; page <= currentPage + siblingCount; page += 1) {
|
||||
if (page >= 1 && page <= pageCount) {
|
||||
pages.add(page)
|
||||
}
|
||||
}
|
||||
|
||||
const sortedPages = [...pages].sort((left, right) => left - right)
|
||||
const items: PaginationItem[] = []
|
||||
let previousPage = 0
|
||||
|
||||
for (const page of sortedPages) {
|
||||
if (previousPage > 0 && page - previousPage > 1) {
|
||||
items.push({ type: 'ellipsis', key: `ellipsis-${previousPage}-${page}` })
|
||||
}
|
||||
items.push({ type: 'page', page })
|
||||
previousPage = page
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
31
frontend/src/utils/poolSearch.test.ts
Normal file
31
frontend/src/utils/poolSearch.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { searchCuratedPools } from './poolSearch'
|
||||
|
||||
const pools = [
|
||||
{
|
||||
chainId: 138,
|
||||
chainName: 'Chain 138',
|
||||
poolAddress: '0x9e89bAe009adf128782E19e8341996c596ac40dC',
|
||||
lpTokenAddress: '0x9e89bAe009adf128782E19e8341996c596ac40dC',
|
||||
lpTokenType: 'dodo_dvm' as const,
|
||||
venue: 'dodo_pmm' as const,
|
||||
baseSymbol: 'cUSDT',
|
||||
quoteSymbol: 'cUSDC',
|
||||
baseAddress: '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
|
||||
quoteAddress: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
|
||||
},
|
||||
]
|
||||
|
||||
describe('searchCuratedPools', () => {
|
||||
it('finds pool by address', () => {
|
||||
const matches = searchCuratedPools('0x9e89bAe009adf128782E19e8341996c596ac40dC', pools)
|
||||
expect(matches).toHaveLength(1)
|
||||
expect(matches[0].matchReason).toBe('exact pool address')
|
||||
})
|
||||
|
||||
it('finds pool by pair symbol', () => {
|
||||
const matches = searchCuratedPools('cUSDT', pools)
|
||||
expect(matches).toHaveLength(1)
|
||||
expect(matches[0].pairLabel).toBe('cUSDT / cUSDC')
|
||||
})
|
||||
})
|
||||
88
frontend/src/utils/poolSearch.ts
Normal file
88
frontend/src/utils/poolSearch.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import type { CuratedPoolRegistryEntry } from '@/services/api/liquidityPositions'
|
||||
|
||||
export interface PoolSearchRow extends CuratedPoolRegistryEntry {
|
||||
pairLabel: string
|
||||
matchReason: 'exact pool address' | 'exact lp token' | 'pair symbol' | 'symbol prefix'
|
||||
}
|
||||
|
||||
const addressPattern = /^0x[a-f0-9]{40}$/i
|
||||
|
||||
function pairLabel(pool: CuratedPoolRegistryEntry): string {
|
||||
return `${pool.baseSymbol} / ${pool.quoteSymbol}`
|
||||
}
|
||||
|
||||
export function searchCuratedPools(query: string, pools: CuratedPoolRegistryEntry[] = []): PoolSearchRow[] {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed || pools.length === 0) return []
|
||||
|
||||
const lower = trimmed.toLowerCase()
|
||||
const isAddress = addressPattern.test(trimmed)
|
||||
const matches: PoolSearchRow[] = []
|
||||
|
||||
for (const pool of pools) {
|
||||
const label = pairLabel(pool)
|
||||
if (isAddress) {
|
||||
if (
|
||||
pool.poolAddress.toLowerCase() === lower ||
|
||||
pool.lpTokenAddress.toLowerCase() === lower ||
|
||||
pool.baseAddress.toLowerCase() === lower ||
|
||||
pool.quoteAddress.toLowerCase() === lower
|
||||
) {
|
||||
matches.push({
|
||||
...pool,
|
||||
pairLabel: label,
|
||||
matchReason:
|
||||
pool.poolAddress.toLowerCase() === lower
|
||||
? 'exact pool address'
|
||||
: pool.lpTokenAddress.toLowerCase() === lower
|
||||
? 'exact lp token'
|
||||
: 'pair symbol',
|
||||
})
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const symMatch =
|
||||
pool.baseSymbol.toLowerCase() === lower ||
|
||||
pool.quoteSymbol.toLowerCase() === lower ||
|
||||
label.toLowerCase().replace(/\s+/g, '') === lower.replace(/\s+/g, '') ||
|
||||
label.toLowerCase().includes(lower)
|
||||
|
||||
if (symMatch) {
|
||||
matches.push({
|
||||
...pool,
|
||||
pairLabel: label,
|
||||
matchReason:
|
||||
pool.baseSymbol.toLowerCase() === lower || pool.quoteSymbol.toLowerCase() === lower
|
||||
? 'pair symbol'
|
||||
: 'symbol prefix',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const weight: Record<PoolSearchRow['matchReason'], number> = {
|
||||
'exact pool address': 100,
|
||||
'exact lp token': 90,
|
||||
'pair symbol': 70,
|
||||
'symbol prefix': 50,
|
||||
}
|
||||
|
||||
return matches.sort((left, right) => {
|
||||
const reasonCmp = weight[right.matchReason] - weight[left.matchReason]
|
||||
if (reasonCmp !== 0) return reasonCmp
|
||||
return (right.totalLiquidityUsd ?? 0) - (left.totalLiquidityUsd ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
export function findPoolByAddress(
|
||||
address: string,
|
||||
pools: CuratedPoolRegistryEntry[],
|
||||
): CuratedPoolRegistryEntry | undefined {
|
||||
const lower = address.trim().toLowerCase()
|
||||
if (!addressPattern.test(lower)) return undefined
|
||||
return pools.find(
|
||||
(pool) =>
|
||||
pool.poolAddress.toLowerCase() === lower ||
|
||||
pool.lpTokenAddress.toLowerCase() === lower,
|
||||
)
|
||||
}
|
||||
18
frontend/src/utils/publicExplorer.test.ts
Normal file
18
frontend/src/utils/publicExplorer.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
|
||||
import { getPublicExplorerBase } from '@/utils/publicExplorer'
|
||||
|
||||
describe('getPublicExplorerBase', () => {
|
||||
it('matches resolveExplorerApiBase with explorer.d-bis.org SSR fallback', () => {
|
||||
expect(getPublicExplorerBase()).toBe(
|
||||
resolveExplorerApiBase({
|
||||
envValue: process.env.NEXT_PUBLIC_API_URL,
|
||||
serverFallback: 'https://explorer.d-bis.org',
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
it('does not default to blockscout.defi-oracle.io when env is empty on the server', () => {
|
||||
expect(getPublicExplorerBase()).not.toBe('https://blockscout.defi-oracle.io')
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,18 @@
|
||||
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
|
||||
|
||||
export interface PublicFetchMetadata {
|
||||
source: string
|
||||
lastModified: string | null
|
||||
}
|
||||
|
||||
/** Production SSR fallback when NEXT_PUBLIC_API_URL is unset (same host as public explorer). */
|
||||
const DEFAULT_PUBLIC_EXPLORER_BASE = 'https://explorer.d-bis.org'
|
||||
|
||||
export function getPublicExplorerBase(): string {
|
||||
const configured = (process.env.NEXT_PUBLIC_API_URL || '').trim()
|
||||
return configured || 'https://blockscout.defi-oracle.io'
|
||||
return resolveExplorerApiBase({
|
||||
envValue: process.env.NEXT_PUBLIC_API_URL,
|
||||
serverFallback: DEFAULT_PUBLIC_EXPLORER_BASE,
|
||||
})
|
||||
}
|
||||
|
||||
export async function fetchPublicJson<T>(path: string): Promise<T> {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
inferDirectSearchTarget,
|
||||
inferEnsSearchTarget,
|
||||
inferTokenSearchTarget,
|
||||
normalizeExplorerSearchResults,
|
||||
searchWrappedTransportTokens,
|
||||
shouldDeferChain138AddressJump,
|
||||
suggestCuratedTokens,
|
||||
} from './search'
|
||||
|
||||
@@ -41,6 +44,14 @@ describe('inferDirectSearchTarget', () => {
|
||||
expect(inferDirectSearchTarget('cUSDT')).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves defi-oracle.eth from identity registry', () => {
|
||||
expect(inferEnsSearchTarget('defi-oracle.eth')).toEqual({
|
||||
kind: 'address',
|
||||
href: '/addresses/0x4A666F96fC8764181194447A7dFdb7d471b301C8',
|
||||
label: 'Open defi-oracle.eth',
|
||||
})
|
||||
})
|
||||
|
||||
it('detects curated token symbols and addresses', () => {
|
||||
expect(inferTokenSearchTarget('cUSDT', [
|
||||
{ chainId: 138, symbol: 'cUSDT', address: '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22' },
|
||||
@@ -152,3 +163,47 @@ describe('normalizeExplorerSearchResults', () => {
|
||||
).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchWrappedTransportTokens', () => {
|
||||
const rows = [
|
||||
{
|
||||
chainId: 1,
|
||||
chainName: 'Ethereum Mainnet',
|
||||
symbol: 'cWUSDC',
|
||||
address: '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a',
|
||||
},
|
||||
{
|
||||
chainId: 56,
|
||||
chainName: 'BNB Chain',
|
||||
symbol: 'cWUSDC',
|
||||
address: '0x5355148C4740fcc3D7a96F05EdD89AB14851206b',
|
||||
},
|
||||
{
|
||||
chainId: 138,
|
||||
chainName: 'Chain 138',
|
||||
symbol: 'cUSDC',
|
||||
address: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
|
||||
},
|
||||
]
|
||||
|
||||
it('finds exact wrapped addresses on multiple chains', () => {
|
||||
const matches = searchWrappedTransportTokens('0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a', rows)
|
||||
expect(matches).toHaveLength(1)
|
||||
expect(matches[0]).toMatchObject({
|
||||
chainId: 1,
|
||||
symbol: 'cWUSDC',
|
||||
matchReason: 'exact address',
|
||||
})
|
||||
})
|
||||
|
||||
it('finds symbol matches across networks', () => {
|
||||
const matches = searchWrappedTransportTokens('cWUSDC', rows)
|
||||
expect(matches.map((row) => row.chainId)).toEqual([1, 56])
|
||||
})
|
||||
|
||||
it('defers chain 138 address jump for off-home wrapped matches', () => {
|
||||
const matches = searchWrappedTransportTokens('0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a', rows)
|
||||
expect(shouldDeferChain138AddressJump('0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a', matches)).toBe(true)
|
||||
expect(shouldDeferChain138AddressJump('0xf22258f57794CC8E06237084b353Ab30fFfa640b', rows)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { getGruCatalogPosture } from '@/services/api/gruCatalog'
|
||||
import { isEnsName } from '@/utils/ens'
|
||||
import {
|
||||
getRegistryEntryByEns,
|
||||
resolveAddressFromRegistryEns,
|
||||
searchRegistryByQuery,
|
||||
} from '@/utils/web3IdentityRegistry'
|
||||
|
||||
export type DirectSearchTarget =
|
||||
| { kind: 'address'; href: string; label: string }
|
||||
@@ -14,6 +20,19 @@ export interface SearchTokenHint {
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
export interface WrappedTransportRegistryRow {
|
||||
chainId: number
|
||||
chainName: string
|
||||
symbol: string
|
||||
address: string
|
||||
assetClass?: string
|
||||
familyKey?: string
|
||||
}
|
||||
|
||||
export interface WrappedTransportSearchRow extends WrappedTransportRegistryRow {
|
||||
matchReason: 'exact address' | 'exact symbol' | 'symbol prefix' | 'symbol contains'
|
||||
}
|
||||
|
||||
export interface RawExplorerSearchItem {
|
||||
type?: string | null
|
||||
address?: string | null
|
||||
@@ -52,6 +71,43 @@ const addressPattern = /^0x[a-f0-9]{40}$/i
|
||||
const transactionHashPattern = /^0x[a-f0-9]{64}$/i
|
||||
const blockNumberPattern = /^\d+$/
|
||||
|
||||
export function inferRegistrySearchTarget(query: string): DirectSearchTarget | null {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return null
|
||||
|
||||
const ensEntry = getRegistryEntryByEns(trimmed)
|
||||
if (ensEntry) {
|
||||
return {
|
||||
kind: 'address',
|
||||
href: `/addresses/${ensEntry.address}`,
|
||||
label: `Open ${ensEntry.displayName}`,
|
||||
}
|
||||
}
|
||||
|
||||
const matches = searchRegistryByQuery(trimmed)
|
||||
if (matches.length === 1 && matches[0].address.toLowerCase() !== `0x${'0'.repeat(40)}`) {
|
||||
return {
|
||||
kind: 'address',
|
||||
href: `/addresses/${matches[0].address}`,
|
||||
label: `Open ${matches[0].displayName}`,
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function inferEnsSearchTarget(query: string): DirectSearchTarget | null {
|
||||
const trimmed = query.trim()
|
||||
if (!isEnsName(trimmed)) return null
|
||||
const fromRegistry = resolveAddressFromRegistryEns(trimmed)
|
||||
if (!fromRegistry) return null
|
||||
return {
|
||||
kind: 'address',
|
||||
href: `/addresses/${fromRegistry}`,
|
||||
label: `Open ${trimmed}`,
|
||||
}
|
||||
}
|
||||
|
||||
export function inferDirectSearchTarget(query: string): DirectSearchTarget | null {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) {
|
||||
@@ -82,9 +138,81 @@ export function inferDirectSearchTarget(query: string): DirectSearchTarget | nul
|
||||
}
|
||||
}
|
||||
|
||||
const registryTarget = inferRegistrySearchTarget(trimmed)
|
||||
if (registryTarget) return registryTarget
|
||||
|
||||
const ensTarget = inferEnsSearchTarget(trimmed)
|
||||
if (ensTarget) return ensTarget
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function wrappedSymbolScore(query: string, symbol: string): WrappedTransportSearchRow['matchReason'] | null {
|
||||
const q = query.trim().toLowerCase()
|
||||
const sym = symbol.trim().toLowerCase()
|
||||
if (!q || !sym) return null
|
||||
if (sym === q) return 'exact symbol'
|
||||
if (sym.startsWith(q) && q.length >= 2) return 'symbol prefix'
|
||||
if (q.length >= 3 && sym.includes(q)) return 'symbol contains'
|
||||
return null
|
||||
}
|
||||
|
||||
/** Search live cW* / gas mirror registry rows across all configured networks. */
|
||||
export function searchWrappedTransportTokens(
|
||||
query: string,
|
||||
rows: WrappedTransportRegistryRow[] = [],
|
||||
): WrappedTransportSearchRow[] {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed || rows.length === 0) return []
|
||||
|
||||
const lower = trimmed.toLowerCase()
|
||||
const isAddress = addressPattern.test(trimmed)
|
||||
const matches: WrappedTransportSearchRow[] = []
|
||||
|
||||
for (const row of rows) {
|
||||
const address = row.address?.toLowerCase()
|
||||
if (isAddress) {
|
||||
if (address === lower) {
|
||||
matches.push({ ...row, matchReason: 'exact address' })
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const reason = wrappedSymbolScore(trimmed, row.symbol)
|
||||
if (reason) {
|
||||
matches.push({ ...row, matchReason: reason })
|
||||
}
|
||||
}
|
||||
|
||||
const reasonWeight: Record<WrappedTransportSearchRow['matchReason'], number> = {
|
||||
'exact address': 100,
|
||||
'exact symbol': 90,
|
||||
'symbol prefix': 70,
|
||||
'symbol contains': 50,
|
||||
}
|
||||
|
||||
return matches.sort((left, right) => {
|
||||
const reasonCmp = reasonWeight[right.matchReason] - reasonWeight[left.matchReason]
|
||||
if (reasonCmp !== 0) return reasonCmp
|
||||
const chainCmp = left.chainId - right.chainId
|
||||
if (chainCmp !== 0) return chainCmp
|
||||
return left.symbol.localeCompare(right.symbol)
|
||||
})
|
||||
}
|
||||
|
||||
export function shouldDeferChain138AddressJump(
|
||||
query: string,
|
||||
wrappedRows: WrappedTransportSearchRow[],
|
||||
): boolean {
|
||||
const trimmed = query.trim()
|
||||
if (!addressPattern.test(trimmed) || wrappedRows.length === 0) return false
|
||||
const lower = trimmed.toLowerCase()
|
||||
const offHome = wrappedRows.filter(
|
||||
(row) => row.matchReason === 'exact address' && row.address.toLowerCase() === lower && row.chainId !== 138,
|
||||
)
|
||||
return offHome.length > 0
|
||||
}
|
||||
|
||||
export function inferTokenSearchTarget(query: string, tokens: SearchTokenHint[] = []): DirectSearchTarget | null {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) {
|
||||
|
||||
109
frontend/src/utils/tokenMarket.ts
Normal file
109
frontend/src/utils/tokenMarket.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import type { AddressTokenBalance } from '@/services/api/addresses'
|
||||
import type { TokenAggregationMarketSnapshot, TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
|
||||
|
||||
export interface ResolvedTokenMarket {
|
||||
priceUsd?: number
|
||||
liquidityUsd?: number
|
||||
source?: 'token-aggregation' | 'blockscout' | 'derived'
|
||||
pricingKind?: 'spot' | 'lp-share'
|
||||
isCanonicalClone?: boolean
|
||||
}
|
||||
|
||||
export function isLikelyLpReceiptSymbol(symbol: string | undefined): boolean {
|
||||
if (!symbol) return false
|
||||
const upper = symbol.toUpperCase()
|
||||
return upper.includes('DLP') || upper.endsWith('-LP') || upper.startsWith('LP-') || upper === 'LP'
|
||||
}
|
||||
|
||||
export function formatBalanceUsdLabel(
|
||||
priceUsd: number | undefined,
|
||||
balanceUsd: number | undefined,
|
||||
options?: { pricingKind?: 'spot' | 'lp-share'; atTransfer?: boolean },
|
||||
): string {
|
||||
if (options?.pricingKind === 'lp-share') {
|
||||
return 'LP share — USD N/A'
|
||||
}
|
||||
if (balanceUsd != null) {
|
||||
return options?.atTransfer ? `≈ ${formatUsd(balanceUsd)} (at transfer)` : `≈ ${formatUsd(balanceUsd)}`
|
||||
}
|
||||
if (priceUsd == null) {
|
||||
return 'USD unavailable'
|
||||
}
|
||||
return 'USD unavailable'
|
||||
}
|
||||
|
||||
function formatUsd(value: number): string {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: value >= 100 ? 0 : 2,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
function parseUsd(value: string | number | null | undefined): number | undefined {
|
||||
if (value == null) return undefined
|
||||
const numeric = typeof value === 'number' ? value : Number(value)
|
||||
return Number.isFinite(numeric) && numeric > 0 ? numeric : undefined
|
||||
}
|
||||
|
||||
export function resolveTokenMarketForBalance(
|
||||
balance: Pick<AddressTokenBalance, 'token_address' | 'exchange_rate' | 'token_symbol'>,
|
||||
tokenMarkets: Record<string, TokenAggregationTokenSnapshot>,
|
||||
): ResolvedTokenMarket {
|
||||
const key = balance.token_address.toLowerCase()
|
||||
const aggregationSnapshot = tokenMarkets[key]
|
||||
const aggregationMarket: TokenAggregationMarketSnapshot | null | undefined = aggregationSnapshot?.market
|
||||
const aggregationPrice = aggregationMarket?.priceUsd
|
||||
const blockscoutPrice = parseUsd(balance.exchange_rate)
|
||||
const pricingKind = aggregationMarket?.pricingKind
|
||||
?? (isLikelyLpReceiptSymbol(balance.token_symbol ?? aggregationSnapshot?.symbol) ? 'lp-share' : 'spot')
|
||||
const isCanonicalClone = aggregationMarket?.isCanonicalClone
|
||||
|
||||
if (aggregationPrice != null && aggregationPrice > 0) {
|
||||
return {
|
||||
priceUsd: aggregationPrice,
|
||||
liquidityUsd: aggregationMarket?.liquidityUsd,
|
||||
source: 'token-aggregation',
|
||||
pricingKind,
|
||||
isCanonicalClone,
|
||||
}
|
||||
}
|
||||
|
||||
if (blockscoutPrice != null) {
|
||||
return {
|
||||
priceUsd: blockscoutPrice,
|
||||
liquidityUsd: aggregationMarket?.liquidityUsd,
|
||||
source: 'blockscout',
|
||||
pricingKind,
|
||||
isCanonicalClone,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
priceUsd: undefined,
|
||||
liquidityUsd: aggregationMarket?.liquidityUsd,
|
||||
source: 'derived',
|
||||
pricingKind,
|
||||
isCanonicalClone,
|
||||
}
|
||||
}
|
||||
|
||||
export function estimateTokenBalanceUsd(
|
||||
rawAmount: string,
|
||||
decimals: number,
|
||||
priceUsd: number | undefined,
|
||||
): number | undefined {
|
||||
if (priceUsd == null || !(priceUsd > 0) || !rawAmount) return undefined
|
||||
try {
|
||||
const raw = BigInt(rawAmount)
|
||||
if (raw <= 0n) return 0
|
||||
const scale = 10n ** BigInt(decimals)
|
||||
const whole = raw / scale
|
||||
const fraction = raw % scale
|
||||
const amount = Number(whole) + Number(fraction) / Number(scale)
|
||||
if (!Number.isFinite(amount)) return undefined
|
||||
return amount * priceUsd
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
63
frontend/src/utils/useDetailTabQuery.ts
Normal file
63
frontend/src/utils/useDetailTabQuery.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
export function parseDetailTabQuery<T extends string>(
|
||||
value: string | string[] | undefined,
|
||||
validTabs: readonly T[],
|
||||
defaultTab: T,
|
||||
): T {
|
||||
const raw = typeof value === 'string' ? value : Array.isArray(value) ? value[0] : undefined
|
||||
if (raw && validTabs.includes(raw as T)) {
|
||||
return raw as T
|
||||
}
|
||||
return defaultTab
|
||||
}
|
||||
|
||||
export function useDetailTabQuery<T extends string>(
|
||||
validTabs: readonly T[],
|
||||
defaultTab: T,
|
||||
resetKey?: string,
|
||||
) {
|
||||
const router = useRouter()
|
||||
const [activeTab, setActiveTabState] = useState<T>(defaultTab)
|
||||
const tabSet = useMemo(() => new Set(validTabs), [validTabs])
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) {
|
||||
return
|
||||
}
|
||||
setActiveTabState(parseDetailTabQuery(router.query.tab, validTabs, defaultTab))
|
||||
}, [defaultTab, resetKey, router.isReady, router.query.tab, validTabs])
|
||||
|
||||
useEffect(() => {
|
||||
if (!tabSet.has(activeTab)) {
|
||||
setActiveTabState(defaultTab)
|
||||
}
|
||||
}, [activeTab, defaultTab, tabSet])
|
||||
|
||||
const setActiveTab = useCallback(
|
||||
(tab: T) => {
|
||||
if (!tabSet.has(tab)) {
|
||||
return
|
||||
}
|
||||
|
||||
setActiveTabState(tab)
|
||||
|
||||
if (!router.isReady) {
|
||||
return
|
||||
}
|
||||
|
||||
const query = { ...router.query }
|
||||
if (tab === defaultTab) {
|
||||
delete query.tab
|
||||
} else {
|
||||
query.tab = tab
|
||||
}
|
||||
|
||||
void router.replace({ pathname: router.pathname, query }, undefined, { shallow: true })
|
||||
},
|
||||
[defaultTab, router, tabSet],
|
||||
)
|
||||
|
||||
return { activeTab, setActiveTab, isReady: router.isReady }
|
||||
}
|
||||
28
frontend/src/utils/useListPageQuery.test.ts
Normal file
28
frontend/src/utils/useListPageQuery.test.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parsePageQuery } from './useListPageQuery'
|
||||
import { parseDetailTabQuery } from './useDetailTabQuery'
|
||||
|
||||
describe('parsePageQuery', () => {
|
||||
it('defaults to page 1', () => {
|
||||
expect(parsePageQuery(undefined)).toBe(1)
|
||||
expect(parsePageQuery('')).toBe(1)
|
||||
expect(parsePageQuery('0')).toBe(1)
|
||||
expect(parsePageQuery('abc')).toBe(1)
|
||||
})
|
||||
|
||||
it('parses positive integers', () => {
|
||||
expect(parsePageQuery('3')).toBe(3)
|
||||
expect(parsePageQuery(['7'])).toBe(7)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseDetailTabQuery', () => {
|
||||
it('falls back to the default tab', () => {
|
||||
expect(parseDetailTabQuery(undefined, ['balances', 'transfers'] as const, 'balances')).toBe('balances')
|
||||
expect(parseDetailTabQuery('unknown', ['balances', 'transfers'] as const, 'balances')).toBe('balances')
|
||||
})
|
||||
|
||||
it('accepts valid tabs', () => {
|
||||
expect(parseDetailTabQuery('transfers', ['balances', 'transfers'] as const, 'balances')).toBe('transfers')
|
||||
})
|
||||
})
|
||||
47
frontend/src/utils/useListPageQuery.ts
Normal file
47
frontend/src/utils/useListPageQuery.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
export function parsePageQuery(value: string | string[] | undefined): number {
|
||||
const raw = typeof value === 'string' ? value : Array.isArray(value) ? value[0] : undefined
|
||||
if (!raw) {
|
||||
return 1
|
||||
}
|
||||
|
||||
const parsed = parseInt(raw, 10)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 1
|
||||
}
|
||||
|
||||
export function useListPageQuery() {
|
||||
const router = useRouter()
|
||||
const [page, setPageState] = useState(1)
|
||||
|
||||
useEffect(() => {
|
||||
if (!router.isReady) {
|
||||
return
|
||||
}
|
||||
setPageState(parsePageQuery(router.query.page))
|
||||
}, [router.isReady, router.query.page])
|
||||
|
||||
const setPage = useCallback(
|
||||
(nextPage: number) => {
|
||||
const normalized = Math.max(1, Math.floor(nextPage) || 1)
|
||||
setPageState(normalized)
|
||||
|
||||
if (!router.isReady) {
|
||||
return
|
||||
}
|
||||
|
||||
const query = { ...router.query }
|
||||
if (normalized === 1) {
|
||||
delete query.page
|
||||
} else {
|
||||
query.page = String(normalized)
|
||||
}
|
||||
|
||||
void router.replace({ pathname: router.pathname, query }, undefined, { shallow: true })
|
||||
},
|
||||
[router],
|
||||
)
|
||||
|
||||
return { page, setPage, isReady: router.isReady }
|
||||
}
|
||||
45
frontend/src/utils/walletAddEthereumChain.ts
Normal file
45
frontend/src/utils/walletAddEthereumChain.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/** EIP-3085 fields accepted by MetaMask `wallet_addEthereumChain` (strict subset). */
|
||||
export type WalletAddEthereumChainParams = {
|
||||
chainId: string
|
||||
chainName: string
|
||||
rpcUrls: string[]
|
||||
nativeCurrency: { name: string; symbol: string; decimals: number }
|
||||
blockExplorerUrls?: string[]
|
||||
iconUrls?: string[]
|
||||
}
|
||||
|
||||
type WalletChainLike = WalletAddEthereumChainParams & {
|
||||
chainIdDecimal?: number
|
||||
oracles?: unknown
|
||||
shortName?: string
|
||||
infoURL?: string
|
||||
explorerApiUrl?: string
|
||||
}
|
||||
|
||||
function httpsOnly(urls: string[] | undefined): string[] | undefined {
|
||||
if (!urls?.length) return undefined
|
||||
const filtered = urls.filter((url) => typeof url === 'string' && url.startsWith('https://'))
|
||||
return filtered.length > 0 ? filtered : undefined
|
||||
}
|
||||
|
||||
/** Strip `chainIdDecimal`, `oracles`, and other keys MetaMask rejects. */
|
||||
export function toWalletAddEthereumChainParams(
|
||||
chain: WalletChainLike,
|
||||
options?: { preferSingleRpc?: boolean },
|
||||
): WalletAddEthereumChainParams {
|
||||
let rpcUrls = httpsOnly(chain.rpcUrls) ?? ['https://rpc-http-pub.d-bis.org']
|
||||
if (options?.preferSingleRpc && rpcUrls.length > 1) {
|
||||
rpcUrls = [rpcUrls[0]]
|
||||
}
|
||||
const out: WalletAddEthereumChainParams = {
|
||||
chainId: chain.chainId,
|
||||
chainName: chain.chainName,
|
||||
rpcUrls,
|
||||
nativeCurrency: chain.nativeCurrency,
|
||||
}
|
||||
const blockExplorerUrls = httpsOnly(chain.blockExplorerUrls)
|
||||
if (blockExplorerUrls) out.blockExplorerUrls = blockExplorerUrls
|
||||
const iconUrls = httpsOnly(chain.iconUrls)
|
||||
if (iconUrls) out.iconUrls = iconUrls
|
||||
return out
|
||||
}
|
||||
41
frontend/src/utils/walletChainCatalog.ts
Normal file
41
frontend/src/utils/walletChainCatalog.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/** Supported chains for explorer wallet import (matches token-aggregation networks.ts). */
|
||||
export const WALLET_IMPORT_CHAIN_IDS = [
|
||||
138, 1, 651940, 56, 137, 100, 10, 42161, 8453, 43114, 25, 42220, 1111,
|
||||
] as const
|
||||
|
||||
export type WalletImportChainId = (typeof WALLET_IMPORT_CHAIN_IDS)[number]
|
||||
|
||||
export const WALLET_FEATURED_SYMBOLS_BY_CHAIN: Partial<Record<number, readonly string[]>> = {
|
||||
138: ['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cXAUT', 'WETH', 'LINK'],
|
||||
1: ['cWUSDC', 'cWUSDT', 'cUSDC', 'cUSDT', 'USDC', 'USDT', 'WETH'],
|
||||
651940: ['cUSDC', 'cUSDT', 'AUSDC', 'AUSDT'],
|
||||
56: ['cWUSDC', 'cWUSDT', 'cUSDC', 'cUSDT'],
|
||||
137: ['cWUSDC', 'cWUSDT', 'cUSDC', 'cUSDT'],
|
||||
100: ['cWUSDC', 'cWUSDT', 'cUSDC', 'cUSDT'],
|
||||
10: ['cWUSDC', 'cWUSDT', 'cUSDC', 'cUSDT'],
|
||||
42161: ['cWUSDC', 'cWUSDT', 'cUSDC', 'cUSDT'],
|
||||
8453: ['cWUSDC', 'cWUSDT', 'cUSDC', 'cUSDT'],
|
||||
43114: ['cWUSDC', 'cWUSDT', 'cUSDC', 'cUSDT'],
|
||||
25: ['cWUSDC', 'cWUSDT', 'cUSDC', 'cUSDT'],
|
||||
42220: ['cWUSDC', 'cWUSDT', 'cUSDC', 'cUSDT'],
|
||||
1111: ['cWUSDC', 'cWUSDT', 'cUSDC', 'cUSDT'],
|
||||
}
|
||||
|
||||
export function chainLabel(chainId: number): string {
|
||||
const labels: Record<number, string> = {
|
||||
138: 'Chain 138',
|
||||
1: 'Ethereum',
|
||||
651940: 'ALL Mainnet',
|
||||
56: 'BSC',
|
||||
137: 'Polygon',
|
||||
100: 'Gnosis',
|
||||
10: 'Optimism',
|
||||
42161: 'Arbitrum',
|
||||
8453: 'Base',
|
||||
43114: 'Avalanche',
|
||||
25: 'Cronos',
|
||||
42220: 'Celo',
|
||||
1111: 'Wemix',
|
||||
}
|
||||
return labels[chainId] ?? `Chain ${chainId}`
|
||||
}
|
||||
48
frontend/src/utils/walletFundedTokenListing.test.ts
Normal file
48
frontend/src/utils/walletFundedTokenListing.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
formatFundedRowBalanceUsd,
|
||||
formatFundedRowUnitPrice,
|
||||
sortFundedWalletTokenRows,
|
||||
type FundedWalletTokenRow,
|
||||
} from './walletFundedTokenListing'
|
||||
|
||||
describe('walletFundedTokenListing', () => {
|
||||
it('sorts native ETH first then by balance USD', () => {
|
||||
const rows: FundedWalletTokenRow[] = [
|
||||
{
|
||||
kind: 'erc20',
|
||||
symbol: 'WETH',
|
||||
name: 'Wrapped Ether',
|
||||
address: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
|
||||
decimals: 18,
|
||||
logoURI: 'https://example/weth.svg',
|
||||
balanceRaw: '1000000000000000000',
|
||||
balanceLabel: '1 WETH',
|
||||
balanceUsd: 100,
|
||||
metaMaskAddable: true,
|
||||
},
|
||||
{
|
||||
kind: 'native',
|
||||
symbol: 'ETH',
|
||||
name: 'Ether',
|
||||
address: null,
|
||||
decimals: 18,
|
||||
logoURI: 'https://example/eth.svg',
|
||||
balanceRaw: '2000000000000000000',
|
||||
balanceLabel: '2 ETH',
|
||||
balanceUsd: 200,
|
||||
metaMaskAddable: false,
|
||||
},
|
||||
]
|
||||
|
||||
const sorted = sortFundedWalletTokenRows(rows)
|
||||
expect(sorted[0]?.kind).toBe('native')
|
||||
expect(sorted[1]?.symbol).toBe('WETH')
|
||||
})
|
||||
|
||||
it('formats USD labels', () => {
|
||||
expect(formatFundedRowUnitPrice({ priceUsd: 1700.25 })).toContain('$')
|
||||
expect(formatFundedRowBalanceUsd({ balanceUsd: 42.5 })).toContain('≈')
|
||||
expect(formatFundedRowUnitPrice({ pricingKind: 'lp-share' })).toContain('LP share')
|
||||
})
|
||||
})
|
||||
206
frontend/src/utils/walletFundedTokenListing.ts
Normal file
206
frontend/src/utils/walletFundedTokenListing.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
import { getNativeAssetMarketSafe, estimateNativeUsdValue } from '@/services/api/nativeAssetPricing'
|
||||
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
|
||||
import { formatTokenAmount } from '@/utils/format'
|
||||
import { estimateTokenBalanceUsd } from '@/utils/tokenMarket'
|
||||
import {
|
||||
formatNativeEthFromWei,
|
||||
listTokensWithNonZeroBalance,
|
||||
readNativeBalanceRaw,
|
||||
type EthereumRpcProvider,
|
||||
} from '@/utils/walletTokenBalances'
|
||||
|
||||
export const CHAIN138_NATIVE_ETH_LOGO =
|
||||
'https://explorer.d-bis.org/api/v1/report/logo/ETH?v=20260510'
|
||||
|
||||
export type FundedWalletCatalogToken = {
|
||||
chainId: number
|
||||
address: string
|
||||
symbol: string
|
||||
name: string
|
||||
decimals: number
|
||||
logoURI?: string
|
||||
}
|
||||
|
||||
export type FundedWalletTokenRow = {
|
||||
kind: 'native' | 'erc20'
|
||||
symbol: string
|
||||
name: string
|
||||
address: string | null
|
||||
decimals: number
|
||||
logoURI: string
|
||||
balanceRaw: string
|
||||
balanceLabel: string
|
||||
priceUsd?: number
|
||||
balanceUsd?: number
|
||||
liquidityUsd?: number
|
||||
pricingKind?: string
|
||||
marketSource?: string
|
||||
metaMaskAddable: boolean
|
||||
}
|
||||
|
||||
function formatListingUsd(value: number | undefined): string {
|
||||
if (value == null || !Number.isFinite(value)) return 'USD unavailable'
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: value >= 100 ? 0 : 2,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
export function formatFundedRowUnitPrice(row: Pick<FundedWalletTokenRow, 'priceUsd' | 'pricingKind'>): string {
|
||||
if (row.pricingKind === 'lp-share') return 'LP share — USD N/A'
|
||||
if (row.priceUsd == null) return 'USD unavailable'
|
||||
return formatListingUsd(row.priceUsd)
|
||||
}
|
||||
|
||||
export function formatFundedRowBalanceUsd(row: Pick<FundedWalletTokenRow, 'balanceUsd' | 'pricingKind'>): string {
|
||||
if (row.pricingKind === 'lp-share') return 'LP share — USD N/A'
|
||||
if (row.balanceUsd == null) return 'USD unavailable'
|
||||
return `≈ ${formatListingUsd(row.balanceUsd)}`
|
||||
}
|
||||
|
||||
function marketMapFromSnapshots(snapshots: TokenAggregationTokenSnapshot[]): Map<string, TokenAggregationTokenSnapshot> {
|
||||
const map = new Map<string, TokenAggregationTokenSnapshot>()
|
||||
for (const snapshot of snapshots) {
|
||||
if (snapshot.address) map.set(snapshot.address.toLowerCase(), snapshot)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
/** On-chain funded rows for native ETH + catalog ERC-20 entries with balance > 0. */
|
||||
export async function buildFundedWalletTokenRows(
|
||||
provider: EthereumRpcProvider,
|
||||
walletAddress: string,
|
||||
catalogTokens: FundedWalletCatalogToken[],
|
||||
options?: {
|
||||
nativeLogoUri?: string
|
||||
onProgress?: (current: number, total: number) => void
|
||||
},
|
||||
): Promise<FundedWalletTokenRow[]> {
|
||||
const nativeLogoUri = options?.nativeLogoUri || CHAIN138_NATIVE_ETH_LOGO
|
||||
const nativeWei = await readNativeBalanceRaw(provider, walletAddress)
|
||||
|
||||
const fundedErc20 = await listTokensWithNonZeroBalance(
|
||||
provider,
|
||||
walletAddress,
|
||||
catalogTokens,
|
||||
options?.onProgress,
|
||||
)
|
||||
|
||||
const rows: FundedWalletTokenRow[] = []
|
||||
|
||||
if (nativeWei > 0n) {
|
||||
rows.push({
|
||||
kind: 'native',
|
||||
symbol: 'ETH',
|
||||
name: 'Ether',
|
||||
address: null,
|
||||
decimals: 18,
|
||||
logoURI: nativeLogoUri,
|
||||
balanceRaw: nativeWei.toString(),
|
||||
balanceLabel: formatNativeEthFromWei(nativeWei),
|
||||
metaMaskAddable: false,
|
||||
})
|
||||
}
|
||||
|
||||
for (const token of fundedErc20) {
|
||||
rows.push({
|
||||
kind: 'erc20',
|
||||
symbol: token.symbol,
|
||||
name: token.name,
|
||||
address: token.address,
|
||||
decimals: token.decimals,
|
||||
logoURI: token.logoURI || CHAIN138_NATIVE_ETH_LOGO,
|
||||
balanceRaw: token.balanceRaw,
|
||||
balanceLabel: formatTokenAmount(token.balanceRaw, token.decimals, token.symbol, 6),
|
||||
metaMaskAddable: true,
|
||||
})
|
||||
}
|
||||
|
||||
return rows
|
||||
}
|
||||
|
||||
/** Attach token-aggregation spot prices and USD notionals to funded rows. */
|
||||
export async function enrichFundedWalletTokenRowsWithMarket(
|
||||
rows: FundedWalletTokenRow[],
|
||||
chainId = 138,
|
||||
): Promise<FundedWalletTokenRow[]> {
|
||||
if (rows.length === 0) return rows
|
||||
|
||||
const erc20Addresses = rows
|
||||
.filter((row) => row.kind === 'erc20' && row.address)
|
||||
.map((row) => row.address as string)
|
||||
|
||||
const [nativeMarket, erc20Market] = await Promise.all([
|
||||
getNativeAssetMarketSafe(chainId),
|
||||
tokenAggregationApi.getTokensByAddressSafe(chainId, erc20Addresses),
|
||||
])
|
||||
|
||||
const markets = marketMapFromSnapshots(erc20Market.ok ? erc20Market.data : [])
|
||||
const nativePriceUsd = nativeMarket.ok ? nativeMarket.data?.market?.priceUsd : undefined
|
||||
|
||||
return rows.map((row) => {
|
||||
if (row.kind === 'native') {
|
||||
const balanceUsdText = estimateNativeUsdValue(row.balanceRaw, nativePriceUsd)
|
||||
return {
|
||||
...row,
|
||||
priceUsd: nativePriceUsd,
|
||||
balanceUsd: balanceUsdText != null ? Number(balanceUsdText) : undefined,
|
||||
liquidityUsd: nativeMarket.data?.market?.liquidityUsd,
|
||||
pricingKind: nativeMarket.data?.market?.pricingKind,
|
||||
marketSource: nativeMarket.ok && nativeMarket.data?.market ? 'token-aggregation' : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const market = row.address ? markets.get(row.address.toLowerCase())?.market : undefined
|
||||
const priceUsd = market?.priceUsd
|
||||
const balanceUsd = estimateTokenBalanceUsd(row.balanceRaw, row.decimals, priceUsd)
|
||||
|
||||
return {
|
||||
...row,
|
||||
priceUsd,
|
||||
balanceUsd,
|
||||
liquidityUsd: market?.liquidityUsd,
|
||||
pricingKind: market?.pricingKind,
|
||||
marketSource: market ? 'token-aggregation' : undefined,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function sortFundedWalletTokenRows(rows: FundedWalletTokenRow[]): FundedWalletTokenRow[] {
|
||||
return [...rows].sort((left, right) => {
|
||||
if (left.kind === 'native') return -1
|
||||
if (right.kind === 'native') return 1
|
||||
const leftUsd = left.balanceUsd ?? 0
|
||||
const rightUsd = right.balanceUsd ?? 0
|
||||
if (rightUsd !== leftUsd) return rightUsd - leftUsd
|
||||
return left.symbol.localeCompare(right.symbol)
|
||||
})
|
||||
}
|
||||
|
||||
export function fundedRowsToWatchCatalogTokens(rows: FundedWalletTokenRow[]): FundedWalletCatalogToken[] {
|
||||
return rows
|
||||
.filter((row) => row.kind === 'erc20' && row.address && row.metaMaskAddable)
|
||||
.map((row) => ({
|
||||
chainId: 138,
|
||||
address: row.address as string,
|
||||
symbol: row.symbol,
|
||||
name: row.name,
|
||||
decimals: row.decimals,
|
||||
logoURI: row.logoURI,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function loadFundedWalletTokenListing(
|
||||
provider: EthereumRpcProvider,
|
||||
walletAddress: string,
|
||||
catalogTokens: FundedWalletCatalogToken[],
|
||||
options?: {
|
||||
nativeLogoUri?: string
|
||||
onProgress?: (current: number, total: number) => void
|
||||
},
|
||||
): Promise<FundedWalletTokenRow[]> {
|
||||
const rows = await buildFundedWalletTokenRows(provider, walletAddress, catalogTokens, options)
|
||||
const enriched = await enrichFundedWalletTokenRowsWithMarket(rows)
|
||||
return sortFundedWalletTokenRows(enriched)
|
||||
}
|
||||
29
frontend/src/utils/walletProviderEnv.test.ts
Normal file
29
frontend/src/utils/walletProviderEnv.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import {
|
||||
buildMetaMaskMobileDappUrl,
|
||||
getWatchAssetBatchSize,
|
||||
isMobileBrowser,
|
||||
isWalletInAppBrowser,
|
||||
} from '@/utils/walletProviderEnv'
|
||||
|
||||
describe('walletProviderEnv', () => {
|
||||
it('detects mobile user agents', () => {
|
||||
expect(isMobileBrowser('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)')).toBe(true)
|
||||
expect(isMobileBrowser('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe(false)
|
||||
})
|
||||
|
||||
it('detects wallet in-app browsers', () => {
|
||||
expect(isWalletInAppBrowser('Mozilla/5.0 MetaMaskMobile')).toBe(true)
|
||||
expect(isWalletInAppBrowser('Mozilla/5.0 Chrome/120')).toBe(false)
|
||||
})
|
||||
|
||||
it('builds MetaMask mobile dapp links without protocol prefix', () => {
|
||||
expect(buildMetaMaskMobileDappUrl('https://explorer.d-bis.org/wallet')).toBe(
|
||||
'https://metamask.app.link/dapp/explorer.d-bis.org/wallet',
|
||||
)
|
||||
})
|
||||
|
||||
it('uses smaller watch-asset batches on mobile contexts', () => {
|
||||
expect(getWatchAssetBatchSize(true)).toBe(2)
|
||||
expect(getWatchAssetBatchSize(false)).toBe(Number.POSITIVE_INFINITY)
|
||||
})
|
||||
})
|
||||
106
frontend/src/utils/walletProviderEnv.ts
Normal file
106
frontend/src/utils/walletProviderEnv.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/** Shared detection + provider resolution for mobile wallets and in-app browsers. */
|
||||
|
||||
export type EthereumProvider = {
|
||||
request: (args: { method: string; params?: unknown }) => Promise<unknown>
|
||||
isMetaMask?: boolean
|
||||
providers?: EthereumProvider[]
|
||||
}
|
||||
|
||||
/** Accept WalletConnect / injected providers with slightly different request typings. */
|
||||
export function asWalletEthereumProvider(provider: unknown): EthereumProvider | undefined {
|
||||
if (!provider || typeof provider !== 'object') return undefined
|
||||
const candidate = provider as { request?: unknown }
|
||||
if (typeof candidate.request !== 'function') return undefined
|
||||
return provider as EthereumProvider
|
||||
}
|
||||
|
||||
const MOBILE_UA_RE = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini|Mobile/i
|
||||
|
||||
export function isMobileUserAgent(userAgent: string): boolean {
|
||||
return MOBILE_UA_RE.test(userAgent)
|
||||
}
|
||||
|
||||
/** True for phone/tablet browsers (not a guarantee of in-app wallet). */
|
||||
export function isMobileBrowser(userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : ''): boolean {
|
||||
return isMobileUserAgent(userAgent)
|
||||
}
|
||||
|
||||
export function isWalletInAppBrowser(userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : ''): boolean {
|
||||
const ua = userAgent.toLowerCase()
|
||||
return (
|
||||
ua.includes('metamask') ||
|
||||
ua.includes('trust') ||
|
||||
ua.includes('imtoken') ||
|
||||
ua.includes('coinbase') ||
|
||||
ua.includes('rainbow') ||
|
||||
ua.includes('status') ||
|
||||
ua.includes('phantom') ||
|
||||
ua.includes('zerion')
|
||||
)
|
||||
}
|
||||
|
||||
export function isMobileWalletContext(userAgent = typeof navigator !== 'undefined' ? navigator.userAgent : ''): boolean {
|
||||
return isMobileBrowser(userAgent) || isWalletInAppBrowser(userAgent)
|
||||
}
|
||||
|
||||
export function resolveInjectedEthereumProvider(
|
||||
win: Window & { ethereum?: EthereumProvider } = typeof window !== 'undefined'
|
||||
? (window as Window & { ethereum?: EthereumProvider })
|
||||
: ({} as Window & { ethereum?: EthereumProvider }),
|
||||
): EthereumProvider | undefined {
|
||||
const eth = win.ethereum
|
||||
if (!eth) return undefined
|
||||
|
||||
const providers = Array.isArray(eth.providers) ? eth.providers.filter(Boolean) : []
|
||||
if (providers.length > 0) {
|
||||
const metamask = providers.find((provider) => provider.isMetaMask)
|
||||
return metamask || providers[0]
|
||||
}
|
||||
|
||||
return eth
|
||||
}
|
||||
|
||||
export function resolveWalletEthereumProvider(
|
||||
walletConnectProvider?: unknown,
|
||||
win: Window & { ethereum?: EthereumProvider } = typeof window !== 'undefined'
|
||||
? (window as Window & { ethereum?: EthereumProvider })
|
||||
: ({} as Window & { ethereum?: EthereumProvider }),
|
||||
): EthereumProvider | undefined {
|
||||
return resolveInjectedEthereumProvider(win) ?? asWalletEthereumProvider(walletConnectProvider)
|
||||
}
|
||||
|
||||
/** MetaMask mobile deep link — opens the in-app dApp browser (required for EIP-747 on mobile Safari/Chrome). */
|
||||
export function buildMetaMaskMobileDappUrl(pageUrl: string): string {
|
||||
try {
|
||||
const parsed = new URL(pageUrl)
|
||||
const hostPath = `${parsed.host}${parsed.pathname}${parsed.search}${parsed.hash}`
|
||||
return `https://metamask.app.link/dapp/${hostPath.replace(/^\/+/, '')}`
|
||||
} catch {
|
||||
const stripped = pageUrl.replace(/^https?:\/\//i, '')
|
||||
return `https://metamask.app.link/dapp/${stripped}`
|
||||
}
|
||||
}
|
||||
|
||||
export function buildCoinbaseWalletDappUrl(pageUrl: string): string {
|
||||
return `https://go.cb-w.com/dapp?cb_url=${encodeURIComponent(pageUrl)}`
|
||||
}
|
||||
|
||||
/** Delay between sequential wallet_watchAsset prompts — mobile wallets reject rapid-fire RPC. */
|
||||
export function getWatchAssetInterPromptDelayMs(mobile = isMobileWalletContext()): number {
|
||||
return mobile ? 650 : 120
|
||||
}
|
||||
|
||||
/** Tokens per user gesture on mobile; desktop runs the full list in one click. */
|
||||
export function getWatchAssetBatchSize(mobile = isMobileWalletContext()): number {
|
||||
return mobile ? 2 : Number.POSITIVE_INFINITY
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const timer = typeof window !== 'undefined' ? window.setTimeout : setTimeout
|
||||
timer(resolve, ms)
|
||||
})
|
||||
}
|
||||
|
||||
export const MOBILE_WALLET_BUTTON_CLASS =
|
||||
'min-h-[44px] touch-manipulation select-none active:scale-[0.98]'
|
||||
62
frontend/src/utils/walletTokenBalances.test.ts
Normal file
62
frontend/src/utils/walletTokenBalances.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
encodeErc20BalanceOfCall,
|
||||
filterTokensWithNonZeroBalance,
|
||||
formatNativeEthFromWei,
|
||||
parseHexBigInt,
|
||||
} from './walletTokenBalances'
|
||||
|
||||
describe('walletTokenBalances', () => {
|
||||
it('encodes balanceOf calldata', () => {
|
||||
expect(
|
||||
encodeErc20BalanceOfCall('0x4A666F96fC8764181194447A7dFdb7d471b301C8'),
|
||||
).toBe(
|
||||
'0x70a08231' +
|
||||
'0000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c8',
|
||||
)
|
||||
})
|
||||
|
||||
it('parses hex bigint values', () => {
|
||||
expect(parseHexBigInt('0x0')).toBe(0n)
|
||||
expect(parseHexBigInt('0x1')).toBe(1n)
|
||||
expect(parseHexBigInt('0x')).toBe(0n)
|
||||
})
|
||||
|
||||
it('filters tokens with non-zero balance and skips undeployed contracts', async () => {
|
||||
const tokens = [
|
||||
{ address: '0x0000000000000000000000000000000000000001', symbol: 'A' },
|
||||
{ address: '0x0000000000000000000000000000000000000002', symbol: 'B' },
|
||||
{ address: '0x0000000000000000000000000000000000000003', symbol: 'C' },
|
||||
]
|
||||
|
||||
const provider = {
|
||||
request: async ({ method, params }: { method: string; params?: unknown }) => {
|
||||
if (method === 'eth_getCode') {
|
||||
const [addr] = params as [string]
|
||||
if (addr.endsWith('0003')) return '0x'
|
||||
return '0x6000'
|
||||
}
|
||||
if (method === 'eth_call') {
|
||||
const [call] = params as [{ to: string }]
|
||||
if (call.to.endsWith('0001')) return '0x0'
|
||||
if (call.to.endsWith('0002')) return '0x2a'
|
||||
return '0x0'
|
||||
}
|
||||
throw new Error(`unexpected ${method}`)
|
||||
},
|
||||
}
|
||||
|
||||
const funded = await filterTokensWithNonZeroBalance(
|
||||
provider,
|
||||
'0x4A666F96fC8764181194447A7dFdb7d471b301C8',
|
||||
tokens,
|
||||
)
|
||||
|
||||
expect(funded.map((token) => token.symbol)).toEqual(['B'])
|
||||
})
|
||||
|
||||
it('formats native ETH from wei', () => {
|
||||
expect(formatNativeEthFromWei(10n ** 18n)).toBe('1 ETH')
|
||||
expect(formatNativeEthFromWei(1500000000000000000n)).toBe('1.5 ETH')
|
||||
})
|
||||
})
|
||||
109
frontend/src/utils/walletTokenBalances.ts
Normal file
109
frontend/src/utils/walletTokenBalances.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
export type EthereumRpcProvider = {
|
||||
request: (args: { method: string; params?: unknown }) => Promise<unknown>
|
||||
}
|
||||
|
||||
export type WalletBalanceToken = {
|
||||
address: string
|
||||
}
|
||||
|
||||
const BALANCE_OF_SELECTOR = '0x70a08231'
|
||||
|
||||
/** ERC-20 balanceOf(address) calldata for eth_call. */
|
||||
export function encodeErc20BalanceOfCall(walletAddress: string): string {
|
||||
const addr = walletAddress.toLowerCase().replace(/^0x/, '')
|
||||
if (!/^[\da-f]{40}$/.test(addr)) {
|
||||
throw new Error('invalid wallet address')
|
||||
}
|
||||
return BALANCE_OF_SELECTOR + addr.padStart(64, '0')
|
||||
}
|
||||
|
||||
export function parseHexBigInt(value: unknown): bigint {
|
||||
if (typeof value !== 'string' || !value.startsWith('0x')) return 0n
|
||||
if (value === '0x') return 0n
|
||||
return BigInt(value)
|
||||
}
|
||||
|
||||
export async function hasContractCode(
|
||||
provider: EthereumRpcProvider,
|
||||
tokenAddress: string,
|
||||
): Promise<boolean> {
|
||||
const code = (await provider.request({
|
||||
method: 'eth_getCode',
|
||||
params: [tokenAddress, 'latest'],
|
||||
})) as string
|
||||
return typeof code === 'string' && code.length > 2
|
||||
}
|
||||
|
||||
export async function readErc20BalanceRaw(
|
||||
provider: EthereumRpcProvider,
|
||||
tokenAddress: string,
|
||||
walletAddress: string,
|
||||
): Promise<bigint> {
|
||||
const result = (await provider.request({
|
||||
method: 'eth_call',
|
||||
params: [{ to: tokenAddress, data: encodeErc20BalanceOfCall(walletAddress) }, 'latest'],
|
||||
})) as string
|
||||
return parseHexBigInt(result)
|
||||
}
|
||||
|
||||
export type WalletBalanceTokenWithRaw<T extends WalletBalanceToken = WalletBalanceToken> = T & {
|
||||
balanceRaw: string
|
||||
}
|
||||
|
||||
/** Like filterTokensWithNonZeroBalance but retains raw balances for listings. */
|
||||
export async function listTokensWithNonZeroBalance<T extends WalletBalanceToken>(
|
||||
provider: EthereumRpcProvider,
|
||||
walletAddress: string,
|
||||
tokens: T[],
|
||||
onProgress?: (current: number, total: number) => void,
|
||||
): Promise<Array<WalletBalanceTokenWithRaw<T>>> {
|
||||
const funded: Array<WalletBalanceTokenWithRaw<T>> = []
|
||||
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index]
|
||||
onProgress?.(index + 1, tokens.length)
|
||||
|
||||
const deployed = await hasContractCode(provider, token.address)
|
||||
if (!deployed) continue
|
||||
|
||||
const balance = await readErc20BalanceRaw(provider, token.address, walletAddress)
|
||||
if (balance > 0n) {
|
||||
funded.push({ ...token, balanceRaw: balance.toString() })
|
||||
}
|
||||
}
|
||||
|
||||
return funded
|
||||
}
|
||||
|
||||
export async function readNativeBalanceRaw(
|
||||
provider: EthereumRpcProvider,
|
||||
walletAddress: string,
|
||||
): Promise<bigint> {
|
||||
const result = (await provider.request({
|
||||
method: 'eth_getBalance',
|
||||
params: [walletAddress, 'latest'],
|
||||
})) as string
|
||||
return parseHexBigInt(result)
|
||||
}
|
||||
|
||||
/** Keep catalog tokens whose on-chain ERC-20 balance is > 0 (skip undeployed addresses). */
|
||||
export async function filterTokensWithNonZeroBalance<T extends WalletBalanceToken>(
|
||||
provider: EthereumRpcProvider,
|
||||
walletAddress: string,
|
||||
tokens: T[],
|
||||
onProgress?: (current: number, total: number) => void,
|
||||
): Promise<T[]> {
|
||||
const funded = await listTokensWithNonZeroBalance(provider, walletAddress, tokens, onProgress)
|
||||
return funded.map(({ balanceRaw: _ignored, ...token }) => token as unknown as T)
|
||||
}
|
||||
|
||||
export function formatNativeEthFromWei(wei: bigint, maxFractionDigits = 4): string {
|
||||
const whole = wei / 10n ** 18n
|
||||
const fraction = wei % 10n ** 18n
|
||||
if (fraction === 0n) return `${whole.toLocaleString()} ETH`
|
||||
|
||||
const fractionStr = fraction.toString().padStart(18, '0').replace(/0+$/, '')
|
||||
const trimmed = fractionStr.slice(0, maxFractionDigits).replace(/0+$/, '')
|
||||
if (!trimmed) return `${whole.toLocaleString()} ETH`
|
||||
return `${whole.toLocaleString()}.${trimmed} ETH`
|
||||
}
|
||||
26
frontend/src/utils/walletWatchAsset.test.ts
Normal file
26
frontend/src/utils/walletWatchAsset.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { buildWatchAssetRpcRequest } from '@/utils/walletWatchAsset'
|
||||
|
||||
describe('buildWatchAssetRpcRequest', () => {
|
||||
const token = {
|
||||
chainId: 138,
|
||||
address: '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
|
||||
symbol: 'cUSDT',
|
||||
decimals: 6,
|
||||
logoURI: 'https://example.com/cusdt.svg',
|
||||
}
|
||||
|
||||
it('uses numeric chainId on Ethereum mainnet desktop', () => {
|
||||
const req = buildWatchAssetRpcRequest({ ...token, chainId: 1 }, false)
|
||||
expect(req.params.options.chainId).toBe(1)
|
||||
})
|
||||
|
||||
it('uses hex chainId on Chain 138 desktop', () => {
|
||||
const req = buildWatchAssetRpcRequest(token, false)
|
||||
expect(req.params.options.chainId).toBe('0x8a')
|
||||
})
|
||||
|
||||
it('uses hex chainId on mobile', () => {
|
||||
const req = buildWatchAssetRpcRequest(token, true)
|
||||
expect(req.params.options.chainId).toBe('0x8a')
|
||||
})
|
||||
})
|
||||
102
frontend/src/utils/walletWatchAsset.ts
Normal file
102
frontend/src/utils/walletWatchAsset.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import {
|
||||
getWatchAssetBatchSize,
|
||||
getWatchAssetInterPromptDelayMs,
|
||||
isMobileWalletContext,
|
||||
sleep,
|
||||
type EthereumProvider,
|
||||
} from '@/utils/walletProviderEnv'
|
||||
|
||||
export type WatchAssetToken = {
|
||||
chainId: number
|
||||
address: string
|
||||
symbol: string
|
||||
decimals: number
|
||||
logoURI?: string
|
||||
}
|
||||
|
||||
export function buildWatchAssetRpcRequest(token: WatchAssetToken, mobile = isMobileWalletContext()) {
|
||||
const options: {
|
||||
address: string
|
||||
symbol: string
|
||||
decimals: number
|
||||
image?: string
|
||||
chainId?: number | string
|
||||
} = {
|
||||
address: token.address,
|
||||
symbol: token.symbol,
|
||||
decimals: token.decimals,
|
||||
}
|
||||
|
||||
if (token.logoURI) {
|
||||
options.image = token.logoURI
|
||||
}
|
||||
|
||||
// Custom networks (Chain 138, ALL Mainnet, …) need explicit hex chainId or MetaMask may
|
||||
// attach the token to the wrong network and balances disappear after refresh.
|
||||
options.chainId =
|
||||
token.chainId === 1 && !mobile ? token.chainId : `0x${token.chainId.toString(16)}`
|
||||
|
||||
return {
|
||||
method: 'wallet_watchAsset' as const,
|
||||
params: {
|
||||
type: 'ERC20' as const,
|
||||
options,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export type WatchAssetBatchResult = {
|
||||
addedCount: number
|
||||
nextIndex: number
|
||||
stoppedEarly: boolean
|
||||
errorMessage?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Process up to `batchSize` wallet_watchAsset prompts starting at `startIndex`.
|
||||
* Call again from a fresh button click when `nextIndex < tokens.length` on mobile.
|
||||
*/
|
||||
export async function runWatchAssetBatch(
|
||||
provider: EthereumProvider,
|
||||
tokens: WatchAssetToken[],
|
||||
startIndex: number,
|
||||
options?: {
|
||||
batchSize?: number
|
||||
mobile?: boolean
|
||||
onProgress?: (current: number, total: number) => void
|
||||
},
|
||||
): Promise<WatchAssetBatchResult> {
|
||||
const mobile = options?.mobile ?? isMobileWalletContext()
|
||||
const batchSize = options?.batchSize ?? getWatchAssetBatchSize(mobile)
|
||||
const delayMs = getWatchAssetInterPromptDelayMs(mobile)
|
||||
const endIndex = Math.min(tokens.length, startIndex + batchSize)
|
||||
let addedCount = 0
|
||||
|
||||
for (let index = startIndex; index < endIndex; index += 1) {
|
||||
const token = tokens[index]
|
||||
options?.onProgress?.(index + 1, tokens.length)
|
||||
|
||||
if (index > startIndex && delayMs > 0) {
|
||||
await sleep(delayMs)
|
||||
}
|
||||
|
||||
try {
|
||||
const added = await provider.request(buildWatchAssetRpcRequest(token, mobile))
|
||||
if (added) addedCount += 1
|
||||
} catch (e) {
|
||||
const err = e as { message?: string }
|
||||
return {
|
||||
addedCount,
|
||||
nextIndex: index,
|
||||
stoppedEarly: true,
|
||||
errorMessage: err.message || `Stopped while adding ${token.symbol}.`,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
addedCount,
|
||||
nextIndex: endIndex,
|
||||
stoppedEarly: false,
|
||||
}
|
||||
}
|
||||
40
frontend/src/utils/walletWatchEligible.test.ts
Normal file
40
frontend/src/utils/walletWatchEligible.test.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
CHAIN138_PLACEHOLDER_GAS_SYMBOLS,
|
||||
dedupeWalletWatchTokens,
|
||||
isDeterministicPlaceholderAddress,
|
||||
isWalletWatchEligibleAddress,
|
||||
} from '@/utils/walletWatchEligible'
|
||||
|
||||
describe('walletWatchEligible', () => {
|
||||
it('detects GRU transport placeholders on Chain 138', () => {
|
||||
expect(isDeterministicPlaceholderAddress('0xcaaa00000000000000000000000000000000008a')).toBe(true)
|
||||
expect(isDeterministicPlaceholderAddress('0x93E66202A11B1772E55407B32B44e5Cd8eda7f22')).toBe(false)
|
||||
})
|
||||
|
||||
it('marks live canonical addresses as wallet-eligible', () => {
|
||||
expect(isWalletWatchEligibleAddress('0xf22258f57794CC8E06237084b353Ab30fFfa640b')).toBe(true)
|
||||
expect(isWalletWatchEligibleAddress('0xcaaa00000000000000000000000000000000008a')).toBe(false)
|
||||
})
|
||||
|
||||
it('documents the nine gas-family placeholder symbols', () => {
|
||||
expect(CHAIN138_PLACEHOLDER_GAS_SYMBOLS).toHaveLength(9)
|
||||
})
|
||||
|
||||
it('dedupes wallet watch tokens by symbol preferring live deployments', () => {
|
||||
const deduped = dedupeWalletWatchTokens([
|
||||
{
|
||||
chainId: 138,
|
||||
symbol: 'cUSDC',
|
||||
address: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
|
||||
},
|
||||
{
|
||||
chainId: 138,
|
||||
symbol: 'cUSDC',
|
||||
address: '0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d',
|
||||
extensions: { deploymentVersion: 'v2', deploymentStatus: 'staged' },
|
||||
},
|
||||
])
|
||||
expect(deduped).toHaveLength(1)
|
||||
expect(deduped[0]?.address).toBe('0xf22258f57794CC8E06237084b353Ab30fFfa640b')
|
||||
})
|
||||
})
|
||||
55
frontend/src/utils/walletWatchEligible.ts
Normal file
55
frontend/src/utils/walletWatchEligible.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/** GRU transport placeholders (e.g. 0xcaaa…008a) — not deployed ERC-20 contracts. */
|
||||
export function isDeterministicPlaceholderAddress(address: string): boolean {
|
||||
const normalized = address.toLowerCase()
|
||||
return /^0x[a-f0-9]{4}0{24,}[a-f0-9]{1,8}$/.test(normalized)
|
||||
}
|
||||
|
||||
export function isWalletWatchEligibleAddress(address?: string | null): boolean {
|
||||
if (!address) return false
|
||||
return !isDeterministicPlaceholderAddress(address)
|
||||
}
|
||||
|
||||
export type WalletWatchTokenLike = {
|
||||
chainId?: number
|
||||
address?: string
|
||||
symbol?: string
|
||||
extensions?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function walletWatchTokenRank(token: WalletWatchTokenLike): number {
|
||||
let rank = 0
|
||||
const extensions = token.extensions || {}
|
||||
const status = String(extensions.deploymentStatus || '').toLowerCase()
|
||||
const version = String(extensions.deploymentVersion || '').toLowerCase()
|
||||
if (status === 'staged') rank += 100
|
||||
if (version === 'v2') rank += 50
|
||||
return rank
|
||||
}
|
||||
|
||||
/** MetaMask stores one custom token row per symbol — keep the live contract address. */
|
||||
export function dedupeWalletWatchTokens<T extends WalletWatchTokenLike>(tokens: T[]): T[] {
|
||||
const bySymbol = new Map<string, T>()
|
||||
for (const token of tokens) {
|
||||
if (!isWalletWatchEligibleAddress(token.address)) continue
|
||||
const key = String(token.symbol || '').trim().toLowerCase()
|
||||
if (!key) continue
|
||||
const existing = bySymbol.get(key)
|
||||
if (!existing || walletWatchTokenRank(token) < walletWatchTokenRank(existing)) {
|
||||
bySymbol.set(key, token)
|
||||
}
|
||||
}
|
||||
return Array.from(bySymbol.values())
|
||||
}
|
||||
|
||||
/** Gas-family roadmap symbols that use deterministic placeholder bindings on Chain 138. */
|
||||
export const CHAIN138_PLACEHOLDER_GAS_SYMBOLS = [
|
||||
'cAVAX',
|
||||
'cBNB',
|
||||
'cCELO',
|
||||
'cCRO',
|
||||
'cETH',
|
||||
'cETHL2',
|
||||
'cPOL',
|
||||
'cWEMIX',
|
||||
'cXDAI',
|
||||
] as const
|
||||
90
frontend/src/utils/web3IdentityRegistry.ts
Normal file
90
frontend/src/utils/web3IdentityRegistry.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import registryDoc from '../../../config/web3-identity-registry.v1.json'
|
||||
|
||||
export interface Web3IdentityEntry {
|
||||
id: string
|
||||
address: string
|
||||
chainIds: number[]
|
||||
roles: string[]
|
||||
displayName: string
|
||||
ens?: { primary: string; chainId: number } | null
|
||||
identifiers?: Array<{ type: string; value: string; entityRef?: string; note?: string }>
|
||||
explorer?: { blockscoutLabel?: string | null; tagTypes?: string[] }
|
||||
eiLabel?: string | null
|
||||
}
|
||||
|
||||
export interface Web3IdentityEntity {
|
||||
id: string
|
||||
displayName: string
|
||||
lei: string
|
||||
entityRef: string
|
||||
roles: string[]
|
||||
}
|
||||
|
||||
export interface Web3IdentityRegistry {
|
||||
schemaVersion: string
|
||||
entries: Web3IdentityEntry[]
|
||||
entities?: Web3IdentityEntity[]
|
||||
}
|
||||
|
||||
const registry = registryDoc as Web3IdentityRegistry
|
||||
|
||||
const byAddress = new Map<string, Web3IdentityEntry>()
|
||||
const byEns = new Map<string, Web3IdentityEntry>()
|
||||
const byId = new Map<string, Web3IdentityEntry>()
|
||||
const byLei = new Map<string, Web3IdentityEntity>()
|
||||
|
||||
for (const entry of registry.entries) {
|
||||
const lower = entry.address.toLowerCase()
|
||||
if (lower === `0x${'0'.repeat(40)}`) continue
|
||||
byAddress.set(lower, entry)
|
||||
byId.set(entry.id, entry)
|
||||
const ensName = entry.ens?.primary?.toLowerCase()
|
||||
if (ensName) byEns.set(ensName, entry)
|
||||
}
|
||||
|
||||
for (const entity of registry.entities ?? []) {
|
||||
byLei.set(entity.lei, entity)
|
||||
}
|
||||
|
||||
export function getRegistryEntryByAddress(address: string): Web3IdentityEntry | undefined {
|
||||
return byAddress.get(address.toLowerCase())
|
||||
}
|
||||
|
||||
export function getRegistryEntryByEns(name: string): Web3IdentityEntry | undefined {
|
||||
return byEns.get(name.trim().toLowerCase())
|
||||
}
|
||||
|
||||
export function getRegistryEntryById(id: string): Web3IdentityEntry | undefined {
|
||||
return byId.get(id)
|
||||
}
|
||||
|
||||
export function getEntityByLei(lei: string): Web3IdentityEntity | undefined {
|
||||
return byLei.get(lei.trim().toUpperCase())
|
||||
}
|
||||
|
||||
/** Registry blockscoutLabel or displayName — not live ENS. */
|
||||
export function getKnownDisplayName(address: string): string | undefined {
|
||||
const entry = getRegistryEntryByAddress(address)
|
||||
if (!entry) return undefined
|
||||
return entry.explorer?.blockscoutLabel || entry.displayName
|
||||
}
|
||||
|
||||
export function resolveAddressFromRegistryEns(name: string): string | undefined {
|
||||
return getRegistryEntryByEns(name)?.address
|
||||
}
|
||||
|
||||
export function searchRegistryByQuery(query: string): Web3IdentityEntry[] {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return []
|
||||
return registry.entries.filter((entry) => {
|
||||
if (entry.address.toLowerCase() === q) return true
|
||||
if (entry.displayName.toLowerCase().includes(q)) return true
|
||||
if (entry.ens?.primary.toLowerCase() === q) return true
|
||||
if (entry.explorer?.blockscoutLabel?.toLowerCase().includes(q)) return true
|
||||
return entry.identifiers?.some(
|
||||
(id) => id.value?.toLowerCase() === q || id.value?.toLowerCase().includes(q),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
export { registry as web3IdentityRegistry }
|
||||
Reference in New Issue
Block a user