feat(explorer): dual-chain wallet metadata, native coin pricing, and UI refresh.
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 <[email protected]>
This commit is contained in:
defiQUG
2026-06-19 16:16:17 -07:00
co-authored by Cursor
parent 0f02e6e54f
commit b87ebee6a1
94 changed files with 7648 additions and 1124 deletions
@@ -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"
@@ -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>
)
}
+3 -2
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
+4 -1
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.' },
],
[],
)
@@ -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>
)
}
+96 -21
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>
)
@@ -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">
+31 -18
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>
@@ -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>
)
}
+388 -71
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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
@@ -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>
)
}
+19 -3
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>
+15 -10
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 = [
+192 -33
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>
+35 -8
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>
+14 -20
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">
+6 -1
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:[email protected]" className="text-primary-600 hover:underline">support@d-bis.org</a>
</p>
<p>
Command center: <a href="/chain138-command-center.html" target="_blank" rel="noopener noreferrer" className="text-primary-600 hover:underline">open visual map →</a>
Command center: <Link href="/topology" className="text-primary-600 hover:underline">open visual map →</Link>
</p>
</div>
</Card>
+16
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} />
}
+174
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>
)
}
+96
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>
)
}
+444 -17
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) &&
+48 -22
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>
+55 -23
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>
+14 -20
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">
+1 -1
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),
])
+1
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 {
+2
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,
}
}
+89
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
}
@@ -0,0 +1,88 @@
import { getTokenAggregationApiBase } from '@/services/api/tokenAggregation'
export interface CuratedPoolRegistryEntry {
chainId: number
chainName: string
poolAddress: string
lpTokenAddress: string
lpTokenType: 'dodo_dvm' | 'univ2_pair'
venue: 'dodo_pmm' | 'uniswap_v2'
baseSymbol: string
quoteSymbol: string
baseAddress: string
quoteAddress: string
status?: string
role?: string
publicRoutingEnabled?: boolean
feeBps?: number
totalLiquidityUsd?: number
reserve0?: string
reserve1?: string
reserve0Usd?: number
reserve1Usd?: number
liquiditySource?: string
liquidityAsOf?: string
integrationStack?: string
}
export interface PoolRegistryResponse {
generatedAt: string
source: string
complete: boolean
count: number
pools: CuratedPoolRegistryEntry[]
}
export interface LpPositionRow {
chainId: number
poolAddress: string
lpTokenAddress: string
lpTokenType: 'dodo_dvm' | 'univ2_pair'
venue: string
pairLabel: string
shareBalanceRaw: string
shareBalanceUnits: string
shareOfPool: number
estimatedUsd: number | null
baseSymbol: string
quoteSymbol: string
status: 'active' | 'zero'
}
export interface LpPositionsResponse {
generatedAt: string
chainId: number
address: string
rpcUsed: boolean
poolsScanned: number
activePositions: number
totalEstimatedUsd: number | null
positions: LpPositionRow[]
notes: string[]
}
const base = () => getTokenAggregationApiBase()
export async function fetchPoolRegistry(chainId?: number): Promise<PoolRegistryResponse | null> {
const query = chainId ? `?chainId=${chainId}` : ''
const response = await fetch(`${base()}/report/pool-registry${query}`, { cache: 'no-store' })
if (!response.ok) return null
return (await response.json()) as PoolRegistryResponse
}
export async function fetchLpPositions(params: {
chainId: number
address: string
hintAddresses?: string[]
}): Promise<LpPositionsResponse | null> {
const search = new URLSearchParams({
chainId: String(params.chainId),
address: params.address,
})
if (params.hintAddresses?.length) {
search.set('hintAddresses', params.hintAddresses.join(','))
}
const response = await fetch(`${base()}/report/lp-positions?${search.toString()}`, { cache: 'no-store' })
if (!response.ok) return null
return (await response.json()) as LpPositionsResponse
}
@@ -20,6 +20,10 @@ export async function getNativeAssetMarketSafe(
chainId: number,
): Promise<{ ok: boolean; data: TokenAggregationTokenSnapshot | null }> {
const descriptor = getNativeAssetDescriptor(chainId)
const batch = await tokenAggregationApi.getTokensByAddressSafe(chainId, [descriptor.pricingAddress])
if (batch.ok && batch.data[0]) {
return { ok: true, data: batch.data[0] }
}
return tokenAggregationApi.getTokenSafe(chainId, descriptor.pricingAddress)
}
@@ -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
}
}
@@ -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,
)
}
+79 -5
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 }
},
+92
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
}
@@ -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
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(' ')
}
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest'
import { buildMeshCounterpartRows, inferMeshSeed } from './meshCounterparts'
const wrappedRows = [
{
chainId: 1,
chainName: 'Ethereum Mainnet',
symbol: 'cWUSDC',
address: '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a',
},
{
chainId: 56,
chainName: 'BNB Chain',
symbol: 'cWUSDC',
address: '0x5355148C4740fcc3D7a96F05EdD89AB14851206b',
},
{
chainId: 138,
chainName: 'Chain 138',
symbol: 'cUSDC',
address: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
},
]
const curated = [
{
chainId: 138,
symbol: 'cUSDC',
address: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
},
]
describe('inferMeshSeed', () => {
it('detects hub symbol from curated tokens', () => {
expect(inferMeshSeed('cUSDC', curated, wrappedRows)).toMatchObject({
hubSymbol: 'cUSDC',
wrappedSymbol: 'cWUSDC',
hubAddress: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
needsHubResolve: false,
matchReason: 'hub symbol',
})
})
it('detects wrapped symbol and resolves hub from registry', () => {
expect(inferMeshSeed('cWUSDC', curated, wrappedRows)).toMatchObject({
hubSymbol: 'cUSDC',
wrappedSymbol: 'cWUSDC',
hubAddress: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
matchReason: 'wrapped symbol',
})
})
it('detects off-home wrapped address and flags hub resolve when hub unknown', () => {
const seed = inferMeshSeed('0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a', [], wrappedRows)
expect(seed).toMatchObject({
hubSymbol: 'cUSDC',
sourceChainId: 1,
sourceAddress: '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a',
needsHubResolve: false,
hubAddress: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
})
})
it('returns null for unrelated queries', () => {
expect(inferMeshSeed('random', curated, wrappedRows)).toBeNull()
})
})
describe('buildMeshCounterpartRows', () => {
it('merges token-mapping resolves with registry rows', () => {
const seed = inferMeshSeed('cUSDC', curated, wrappedRows)!
const resolved = new Map<number, string>([
[138, seed.hubAddress!],
[1, '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a'],
])
const chainNames = new Map<number, string>([
[138, 'Chain 138'],
[1, 'Ethereum Mainnet'],
[56, 'BNB Chain'],
])
const rows = buildMeshCounterpartRows(seed, resolved, wrappedRows, chainNames)
expect(rows.map((row) => row.chainId)).toEqual([138, 1, 56])
expect(rows[0].role).toBe('hub')
expect(rows.filter((row) => row.chainId === 56)).toHaveLength(1)
})
})
+205
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
}
+30
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 },
])
})
})
+43
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
}
+31
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')
})
})
+88
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,
)
}
+18
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')
})
})
+9 -2
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> {
+55
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)
})
})
+128
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) {
+109
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
}
}
+63
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 }
}
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest'
import { parsePageQuery } from './useListPageQuery'
import { parseDetailTabQuery } from './useDetailTabQuery'
describe('parsePageQuery', () => {
it('defaults to page 1', () => {
expect(parsePageQuery(undefined)).toBe(1)
expect(parsePageQuery('')).toBe(1)
expect(parsePageQuery('0')).toBe(1)
expect(parsePageQuery('abc')).toBe(1)
})
it('parses positive integers', () => {
expect(parsePageQuery('3')).toBe(3)
expect(parsePageQuery(['7'])).toBe(7)
})
})
describe('parseDetailTabQuery', () => {
it('falls back to the default tab', () => {
expect(parseDetailTabQuery(undefined, ['balances', 'transfers'] as const, 'balances')).toBe('balances')
expect(parseDetailTabQuery('unknown', ['balances', 'transfers'] as const, 'balances')).toBe('balances')
})
it('accepts valid tabs', () => {
expect(parseDetailTabQuery('transfers', ['balances', 'transfers'] as const, 'balances')).toBe('transfers')
})
})
+47
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 }
}
@@ -0,0 +1,45 @@
/** EIP-3085 fields accepted by MetaMask `wallet_addEthereumChain` (strict subset). */
export type WalletAddEthereumChainParams = {
chainId: string
chainName: string
rpcUrls: string[]
nativeCurrency: { name: string; symbol: string; decimals: number }
blockExplorerUrls?: string[]
iconUrls?: string[]
}
type WalletChainLike = WalletAddEthereumChainParams & {
chainIdDecimal?: number
oracles?: unknown
shortName?: string
infoURL?: string
explorerApiUrl?: string
}
function httpsOnly(urls: string[] | undefined): string[] | undefined {
if (!urls?.length) return undefined
const filtered = urls.filter((url) => typeof url === 'string' && url.startsWith('https://'))
return filtered.length > 0 ? filtered : undefined
}
/** Strip `chainIdDecimal`, `oracles`, and other keys MetaMask rejects. */
export function toWalletAddEthereumChainParams(
chain: WalletChainLike,
options?: { preferSingleRpc?: boolean },
): WalletAddEthereumChainParams {
let rpcUrls = httpsOnly(chain.rpcUrls) ?? ['https://rpc-http-pub.d-bis.org']
if (options?.preferSingleRpc && rpcUrls.length > 1) {
rpcUrls = [rpcUrls[0]]
}
const out: WalletAddEthereumChainParams = {
chainId: chain.chainId,
chainName: chain.chainName,
rpcUrls,
nativeCurrency: chain.nativeCurrency,
}
const blockExplorerUrls = httpsOnly(chain.blockExplorerUrls)
if (blockExplorerUrls) out.blockExplorerUrls = blockExplorerUrls
const iconUrls = httpsOnly(chain.iconUrls)
if (iconUrls) out.iconUrls = iconUrls
return out
}
+41
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}`
}
@@ -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')
})
})
@@ -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)
}
@@ -0,0 +1,29 @@
import {
buildMetaMaskMobileDappUrl,
getWatchAssetBatchSize,
isMobileBrowser,
isWalletInAppBrowser,
} from '@/utils/walletProviderEnv'
describe('walletProviderEnv', () => {
it('detects mobile user agents', () => {
expect(isMobileBrowser('Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)')).toBe(true)
expect(isMobileBrowser('Mozilla/5.0 (Windows NT 10.0; Win64; x64)')).toBe(false)
})
it('detects wallet in-app browsers', () => {
expect(isWalletInAppBrowser('Mozilla/5.0 MetaMaskMobile')).toBe(true)
expect(isWalletInAppBrowser('Mozilla/5.0 Chrome/120')).toBe(false)
})
it('builds MetaMask mobile dapp links without protocol prefix', () => {
expect(buildMetaMaskMobileDappUrl('https://explorer.d-bis.org/wallet')).toBe(
'https://metamask.app.link/dapp/explorer.d-bis.org/wallet',
)
})
it('uses smaller watch-asset batches on mobile contexts', () => {
expect(getWatchAssetBatchSize(true)).toBe(2)
expect(getWatchAssetBatchSize(false)).toBe(Number.POSITIVE_INFINITY)
})
})
+106
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]'
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import {
encodeErc20BalanceOfCall,
filterTokensWithNonZeroBalance,
formatNativeEthFromWei,
parseHexBigInt,
} from './walletTokenBalances'
describe('walletTokenBalances', () => {
it('encodes balanceOf calldata', () => {
expect(
encodeErc20BalanceOfCall('0x4A666F96fC8764181194447A7dFdb7d471b301C8'),
).toBe(
'0x70a08231' +
'0000000000000000000000004a666f96fc8764181194447a7dfdb7d471b301c8',
)
})
it('parses hex bigint values', () => {
expect(parseHexBigInt('0x0')).toBe(0n)
expect(parseHexBigInt('0x1')).toBe(1n)
expect(parseHexBigInt('0x')).toBe(0n)
})
it('filters tokens with non-zero balance and skips undeployed contracts', async () => {
const tokens = [
{ address: '0x0000000000000000000000000000000000000001', symbol: 'A' },
{ address: '0x0000000000000000000000000000000000000002', symbol: 'B' },
{ address: '0x0000000000000000000000000000000000000003', symbol: 'C' },
]
const provider = {
request: async ({ method, params }: { method: string; params?: unknown }) => {
if (method === 'eth_getCode') {
const [addr] = params as [string]
if (addr.endsWith('0003')) return '0x'
return '0x6000'
}
if (method === 'eth_call') {
const [call] = params as [{ to: string }]
if (call.to.endsWith('0001')) return '0x0'
if (call.to.endsWith('0002')) return '0x2a'
return '0x0'
}
throw new Error(`unexpected ${method}`)
},
}
const funded = await filterTokensWithNonZeroBalance(
provider,
'0x4A666F96fC8764181194447A7dFdb7d471b301C8',
tokens,
)
expect(funded.map((token) => token.symbol)).toEqual(['B'])
})
it('formats native ETH from wei', () => {
expect(formatNativeEthFromWei(10n ** 18n)).toBe('1 ETH')
expect(formatNativeEthFromWei(1500000000000000000n)).toBe('1.5 ETH')
})
})
+109
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`
}
@@ -0,0 +1,26 @@
import { buildWatchAssetRpcRequest } from '@/utils/walletWatchAsset'
describe('buildWatchAssetRpcRequest', () => {
const token = {
chainId: 138,
address: '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
symbol: 'cUSDT',
decimals: 6,
logoURI: 'https://example.com/cusdt.svg',
}
it('uses numeric chainId on Ethereum mainnet desktop', () => {
const req = buildWatchAssetRpcRequest({ ...token, chainId: 1 }, false)
expect(req.params.options.chainId).toBe(1)
})
it('uses hex chainId on Chain 138 desktop', () => {
const req = buildWatchAssetRpcRequest(token, false)
expect(req.params.options.chainId).toBe('0x8a')
})
it('uses hex chainId on mobile', () => {
const req = buildWatchAssetRpcRequest(token, true)
expect(req.params.options.chainId).toBe('0x8a')
})
})
+102
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,
}
}
@@ -0,0 +1,40 @@
import {
CHAIN138_PLACEHOLDER_GAS_SYMBOLS,
dedupeWalletWatchTokens,
isDeterministicPlaceholderAddress,
isWalletWatchEligibleAddress,
} from '@/utils/walletWatchEligible'
describe('walletWatchEligible', () => {
it('detects GRU transport placeholders on Chain 138', () => {
expect(isDeterministicPlaceholderAddress('0xcaaa00000000000000000000000000000000008a')).toBe(true)
expect(isDeterministicPlaceholderAddress('0x93E66202A11B1772E55407B32B44e5Cd8eda7f22')).toBe(false)
})
it('marks live canonical addresses as wallet-eligible', () => {
expect(isWalletWatchEligibleAddress('0xf22258f57794CC8E06237084b353Ab30fFfa640b')).toBe(true)
expect(isWalletWatchEligibleAddress('0xcaaa00000000000000000000000000000000008a')).toBe(false)
})
it('documents the nine gas-family placeholder symbols', () => {
expect(CHAIN138_PLACEHOLDER_GAS_SYMBOLS).toHaveLength(9)
})
it('dedupes wallet watch tokens by symbol preferring live deployments', () => {
const deduped = dedupeWalletWatchTokens([
{
chainId: 138,
symbol: 'cUSDC',
address: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
},
{
chainId: 138,
symbol: 'cUSDC',
address: '0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d',
extensions: { deploymentVersion: 'v2', deploymentStatus: 'staged' },
},
])
expect(deduped).toHaveLength(1)
expect(deduped[0]?.address).toBe('0xf22258f57794CC8E06237084b353Ab30fFfa640b')
})
})
+55
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
@@ -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 }