feat(explorer): dual-chain wallet metadata, native coin pricing, and UI refresh.
Some checks failed
Deploy Explorer Live / deploy (push) Failing after 20s
Validate Explorer / frontend (push) Failing after 24s
Validate Explorer / smoke-e2e (push) Has been skipped

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:
defiQUG
2026-06-19 16:16:17 -07:00
parent 0f02e6e54f
commit b87ebee6a1
94 changed files with 7648 additions and 1124 deletions

View File

@@ -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"

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

View File

@@ -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

View File

@@ -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.' },
],
[],
)

View File

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

View File

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

View File

@@ -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">

View File

@@ -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>

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

View File

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

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

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

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

View 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 &quot;-&quot; 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>
)
}

View File

@@ -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>

View File

@@ -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 = [

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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>

View 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} />
}

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

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

View File

@@ -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) &&

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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),
])

View File

@@ -60,6 +60,7 @@ export interface AddressTokenBalance {
value: string
holder_count?: number
total_supply?: string
exchange_rate?: string | number | null
}
export interface AddressTokenTransfer {

View File

@@ -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,
}
}

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

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

View File

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

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

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

View File

@@ -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 }
},

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

View File

@@ -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
View 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(' ')
}

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

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

View 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 },
])
})
})

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

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

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

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

View File

@@ -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> {

View File

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

View File

@@ -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) {

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

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

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

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

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

View 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}`
}

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

View 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 &gt; 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)
}

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

View 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]'

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

View 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 &gt; 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`
}

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

View 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,
}
}

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

View 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

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