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

Add Chain 138 wallet network metadata and stats coin-price enrichment; sync frontend explorer SPA, command center, and address/token pages with backend config.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
defiQUG
2026-06-19 16:16:17 -07:00
parent 0f02e6e54f
commit b87ebee6a1
94 changed files with 7648 additions and 1124 deletions

View File

@@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
import Navbar from './Navbar'
import Footer from './Footer'
import ExplorerAgentTool from './ExplorerAgentTool'
import ExplorerDocumentHead from './ExplorerDocumentHead'
import { UiModeProvider } from './UiModeContext'
import { PostureGlossaryProvider } from './PostureGlossaryProvider'
@@ -9,6 +10,7 @@ export default function ExplorerChrome({ children }: { children: ReactNode }) {
return (
<UiModeProvider>
<PostureGlossaryProvider>
<ExplorerDocumentHead />
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
<a
href="#main-content"

View File

@@ -0,0 +1,56 @@
'use client'
import Head from 'next/head'
import { useRouter } from 'next/router'
const BASE_TITLE = 'DBIS Explorer'
function resolveDocumentTitle(pathname: string, query: Record<string, string | string[] | undefined>): string {
if (pathname === '/') return `${BASE_TITLE} — Chain 138`
if (pathname === '/search') return `Search — ${BASE_TITLE}`
if (pathname === '/wallet') return `Wallet Tools — ${BASE_TITLE}`
if (pathname === '/protocols') return `Official Protocol Contracts — ${BASE_TITLE}`
if (pathname.startsWith('/protocols/')) {
const id = typeof query.id === 'string' ? query.id : pathname.split('/').pop() || 'Protocol'
return `${id} — Protocols — ${BASE_TITLE}`
}
if (pathname === '/pools') return `Pool Registry — ${BASE_TITLE}`
if (pathname.startsWith('/pools/')) return `Pool Detail — ${BASE_TITLE}`
if (pathname === '/liquidity') return `Liquidity — ${BASE_TITLE}`
if (pathname === '/operations') return `Operations — ${BASE_TITLE}`
if (pathname === '/docs') return `Documentation — ${BASE_TITLE}`
if (pathname === '/tokens') return `Tokens — ${BASE_TITLE}`
if (pathname.startsWith('/tokens/')) return `Token — ${BASE_TITLE}`
if (pathname === '/addresses') return `Addresses — ${BASE_TITLE}`
if (pathname.startsWith('/addresses/')) return `Address — ${BASE_TITLE}`
if (pathname === '/blocks') return `Blocks — ${BASE_TITLE}`
if (pathname === '/transactions') return `Transactions — ${BASE_TITLE}`
if (pathname.startsWith('/transactions/')) return `Transaction — ${BASE_TITLE}`
if (pathname === '/bridge') return `Bridge — ${BASE_TITLE}`
if (pathname === '/routes') return `Routes — ${BASE_TITLE}`
if (pathname === '/analytics') return `Analytics — ${BASE_TITLE}`
if (pathname === '/operator') return `Operator — ${BASE_TITLE}`
if (pathname === '/system') return `System — ${BASE_TITLE}`
if (pathname === '/weth') return `WETH — ${BASE_TITLE}`
if (pathname === '/watchlist') return `Watchlist — ${BASE_TITLE}`
if (pathname === '/access') return `Account Access — ${BASE_TITLE}`
const segment = pathname.split('/').filter(Boolean)[0]
if (segment) {
const label = segment.charAt(0).toUpperCase() + segment.slice(1)
return `${label}${BASE_TITLE}`
}
return BASE_TITLE
}
export default function ExplorerDocumentHead() {
const router = useRouter()
const pathname = router.pathname || '/'
const title = resolveDocumentTitle(pathname, router.query)
return (
<Head>
<title>{title}</title>
</Head>
)
}

View File

@@ -47,6 +47,7 @@ export default function Footer() {
<li><Link className={footerLinkClass} href="/routes">Routes</Link></li>
<li><Link className={footerLinkClass} href="/liquidity">Liquidity</Link></li>
<li><Link className={footerLinkClass} href="/pools">Pools</Link></li>
<li><Link className={footerLinkClass} href="/protocols">Protocols</Link></li>
<li><Link className={footerLinkClass} href="/analytics">Analytics</Link></li>
<li><Link className={footerLinkClass} href="/operator">Operator</Link></li>
<li><Link className={footerLinkClass} href="/system">System</Link></li>
@@ -87,9 +88,9 @@ export default function Footer() {
</p>
<p>
Command center:{' '}
<a className={footerLinkClass} href="/chain138-command-center.html" target="_blank" rel="noopener noreferrer">
<Link className={footerLinkClass} href="/topology">
Chain 138 visual map
</a>
</Link>
</p>
<p className="text-xs leading-5 text-gray-500 dark:text-gray-500">
Questions about the explorer, chain metadata, route discovery, or liquidity access

View File

@@ -490,6 +490,7 @@ export default function Navbar() {
pathname.startsWith('/tokens') ||
pathname.startsWith('/analytics') ||
pathname.startsWith('/pools') ||
pathname.startsWith('/protocols') ||
pathname.startsWith('/watchlist')
const isOperationsActive =
pathname.startsWith('/bridge') ||
@@ -593,6 +594,7 @@ export default function Navbar() {
{ href: '/tokens', label: 'Tokens', description: 'Review curated assets, standards, and token detail pages.' },
{ href: '/analytics', label: 'Analytics', description: 'Open explorer-visible transaction and block activity summaries.' },
{ href: '/pools', label: 'Pools', description: 'Browse mission-control pool inventory and route-backed liquidity context.' },
{ href: '/protocols', label: 'Protocols', description: 'Review official upstream protocol contracts and production guardrails on Chain 138.' },
{ href: '/watchlist', label: 'Watchlist', description: 'Jump into tracked addresses and saved explorer entities.' },
],
[],
@@ -603,10 +605,11 @@ export default function Navbar() {
{ href: '/bridge', label: 'Bridge', description: 'Inspect relay lanes, queue posture, and bridge trace tooling.' },
{ href: '/routes', label: 'Routes', description: 'Review live route coverage, same-chain lanes, and bridge paths.' },
{ href: '/liquidity', label: 'Liquidity', description: 'Check planner-backed route access and live liquidity posture.' },
{ href: '/protocols', label: 'Protocols', description: 'Official DODO, UniV2/V3, CCIP, and integration contracts on Chain 138.' },
{ href: '/system', label: 'System', description: 'Inspect topology, RPC capability, and public integration inventory.' },
{ href: '/operator', label: 'Operator', description: 'Open planner, route, and relay shortcuts in one public page.' },
{ href: '/weth', label: 'WETH', description: 'Review wrapped-asset references and bridge-oriented WETH context.' },
{ href: '/chain138-command-center.html', label: 'Command Center', description: 'Open the visual command-center reference.', external: true },
{ href: '/topology', label: 'Command Center', description: 'Open the visual topology and architecture map.' },
],
[],
)

View File

@@ -1,60 +1,97 @@
import { buildPaginationItems } from '@/utils/pagination'
interface PaginationControlsProps {
page: number
pageCount: number
onPageChange: (page: number) => void
label?: string
className?: string
disabled?: boolean
ariaLabel?: string
/** Known total pages for bounded pagination. Omit for cursor-style list pagination. */
pageCount?: number
/** Used when pageCount is omitted. */
hasNextPage?: boolean
}
const buttonClassName =
'rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300'
const activeButtonClassName = 'rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white'
export default function PaginationControls({
page,
pageCount,
hasNextPage = false,
onPageChange,
label = 'Rows',
className = '',
disabled = false,
ariaLabel,
}: PaginationControlsProps) {
if (pageCount <= 1) return null
const isBounded = typeof pageCount === 'number'
const boundedPageCount = pageCount ?? 1
const pages = Array.from({ length: pageCount }, (_, index) => index + 1)
if (isBounded && boundedPageCount <= 1) {
return null
}
if (!isBounded && page <= 1 && !hasNextPage) {
return null
}
const paginationItems = isBounded ? buildPaginationItems(page, boundedPageCount) : []
const canGoPrevious = page > 1
const canGoNext = isBounded ? page < boundedPageCount : hasNextPage
const navLabel = ariaLabel || `${label} pagination`
const statusText = isBounded ? `${label}: page ${page} of ${boundedPageCount}` : `${label}: page ${page}`
return (
<div className={`mt-4 flex flex-wrap items-center justify-between gap-3 ${className}`}>
<div className="text-sm text-gray-600 dark:text-gray-400">
{label}: page {page} of {pageCount}
</div>
<nav
className={`mt-4 flex flex-wrap items-center justify-between gap-3 ${className}`}
aria-label={navLabel}
>
<div className="text-sm text-gray-600 dark:text-gray-400">{statusText}</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={() => onPageChange(Math.max(1, page - 1))}
disabled={page <= 1}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300"
disabled={disabled || !canGoPrevious}
className={buttonClassName}
>
Previous
</button>
{pages.map((candidate) => (
<button
key={candidate}
type="button"
onClick={() => onPageChange(candidate)}
aria-current={candidate === page ? 'page' : undefined}
className={
candidate === page
? 'rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white'
: 'rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300'
}
>
{candidate}
</button>
))}
{isBounded ? (
<ul className="flex flex-wrap items-center gap-2">
{paginationItems.map((item) =>
item.type === 'ellipsis' ? (
<li key={item.key} className="px-1 text-sm text-gray-500 dark:text-gray-400" aria-hidden="true">
</li>
) : (
<li key={item.page}>
<button
type="button"
onClick={() => onPageChange(item.page)}
disabled={disabled}
aria-current={item.page === page ? 'page' : undefined}
className={item.page === page ? activeButtonClassName : buttonClassName}
>
{item.page}
</button>
</li>
),
)}
</ul>
) : null}
<button
type="button"
onClick={() => onPageChange(Math.min(pageCount, page + 1))}
disabled={page >= pageCount}
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300"
onClick={() => onPageChange(isBounded ? Math.min(boundedPageCount, page + 1) : page + 1)}
disabled={disabled || !canGoNext}
className={buttonClassName}
>
Next
</button>
</div>
</div>
</nav>
)
}

View File

@@ -1,3 +1,5 @@
import { useCallback, useId } from 'react'
export interface SectionTab<T extends string> {
id: T
label: string
@@ -9,6 +11,21 @@ interface SectionTabsProps<T extends string> {
activeTab: T
onChange: (tab: T) => void
className?: string
idPrefix?: string
ariaLabel?: string
}
export function sectionTabPanelProps<T extends string>(
idPrefix: string,
tabId: T,
activeTab: T,
) {
return {
id: `${idPrefix}-panel-${tabId}`,
role: 'tabpanel' as const,
'aria-labelledby': `${idPrefix}-tab-${tabId}`,
hidden: activeTab !== tabId,
}
}
export default function SectionTabs<T extends string>({
@@ -16,29 +33,87 @@ export default function SectionTabs<T extends string>({
activeTab,
onChange,
className = '',
idPrefix,
ariaLabel = 'Sections',
}: SectionTabsProps<T>) {
const generatedId = useId().replace(/:/g, '')
const tabListPrefix = idPrefix || generatedId
const focusTab = useCallback(
(tabId: T) => {
const element = document.getElementById(`${tabListPrefix}-tab-${tabId}`)
element?.focus()
},
[tabListPrefix],
)
const handleKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
const currentIndex = tabs.findIndex((tab) => tab.id === activeTab)
if (currentIndex < 0) {
return
}
let nextIndex = currentIndex
if (event.key === 'ArrowRight') {
nextIndex = (currentIndex + 1) % tabs.length
} else if (event.key === 'ArrowLeft') {
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length
} else if (event.key === 'Home') {
nextIndex = 0
} else if (event.key === 'End') {
nextIndex = tabs.length - 1
} else {
return
}
event.preventDefault()
const nextTab = tabs[nextIndex]
onChange(nextTab.id)
focusTab(nextTab.id)
},
[activeTab, focusTab, onChange, tabs],
)
return (
<div className={`sticky top-0 z-20 border-b border-gray-200 bg-white/95 py-3 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 ${className}`}>
<div className="flex gap-2 overflow-x-auto">
{tabs.map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => onChange(tab.id)}
className={
activeTab === tab.id
? 'whitespace-nowrap rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white'
: 'whitespace-nowrap rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300'
}
>
{tab.label}
{typeof tab.count === 'number' ? (
<span className={activeTab === tab.id ? 'ml-2 text-primary-100' : 'ml-2 text-gray-500 dark:text-gray-400'}>
{tab.count.toLocaleString()}
</span>
) : null}
</button>
))}
<div
className={`sticky top-0 z-20 border-b border-gray-200 bg-white/95 py-3 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 ${className}`}
>
<div
role="tablist"
aria-label={ariaLabel}
className="flex gap-2 overflow-x-auto"
onKeyDown={handleKeyDown}
>
{tabs.map((tab) => {
const isActive = activeTab === tab.id
return (
<button
key={tab.id}
id={`${tabListPrefix}-tab-${tab.id}`}
type="button"
role="tab"
aria-selected={isActive}
aria-controls={`${tabListPrefix}-panel-${tab.id}`}
tabIndex={isActive ? 0 : -1}
onClick={() => onChange(tab.id)}
className={
isActive
? 'whitespace-nowrap rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white'
: 'whitespace-nowrap rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300'
}
>
{tab.label}
{typeof tab.count === 'number' ? (
<span className={isActive ? 'ml-2 text-primary-100' : 'ml-2 text-gray-500 dark:text-gray-400'}>
{tab.count.toLocaleString()}
</span>
) : null}
</button>
)
})}
</div>
</div>
)

View File

@@ -26,6 +26,7 @@ import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
import OperationsSurfaceNav from './OperationsSurfaceNav'
import LpPositionPanel from '@/components/wallet/LpPositionPanel'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
import {
formatCurrency,
@@ -252,6 +253,12 @@ export default function LiquidityOperationsPage({
href: `/api/v1/report/external-indexer-readiness?chainId=138`,
notes: 'One JSON posture for DefiLlama, CoinGecko, CoinMarketCap, and Dexscreener readiness.',
},
{
name: 'Curated pool registry',
method: 'GET',
href: `${tokenAggregationV1Base}/report/pool-registry?chainId=138`,
notes: 'DODO PMM + UniV2 pools with lpTokenType metadata; LP receipt tokens are not bridgeable.',
},
]
const copyEndpoint = async (endpoint: EndpointCard) => {
@@ -480,6 +487,20 @@ export default function LiquidityOperationsPage({
</Card>
</div>
<div className="mb-8">
<Card title="Curated LP registry (Chain 138)">
<LpPositionPanel chainId={138} title="Pool registry & LP scan" compact />
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">
Connect a wallet on the{' '}
<Link href="/wallet" className="font-medium text-primary-600 hover:underline dark:text-primary-400">
Wallet
</Link>{' '}
page to scan your LP shares and estimated USD NAV. LP tokens are chain-local bridge underlying c*/cW*
instead.
</p>
</Card>
</div>
<div className="mb-8">
<Card title="Explorer Access Points">
<div className="grid gap-4 md:grid-cols-2">

View File

@@ -991,34 +991,47 @@ export default function Home({
</Card>
</div>
<Card title="Quick links" className="mt-8">
<Card title="Institutional quick paths" className="mt-8">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
Jump to the explorer surfaces used most often for discovery, liquidity, wallet setup, and bridge monitoring.
Canonical discovery, compliance, and liquidity surfaces for institutional users mesh tokens, official
protocols, curated pools, and public JSON APIs.
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<Link href="/search" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Search
<Link href="/search?q=cWUSDC" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Mesh search</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">c* cW* across chains</div>
</Link>
<Link href="/access" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Account access
<Link href="/protocols" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Official protocols</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Upstream contracts + guardrails</div>
</Link>
<Link href="/tokens" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Tokens
<Link href="/pools" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Pool registry</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Live DODO PMM + UniV2 TVL</div>
</Link>
<Link href="/wallet" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Wallet & MetaMask
<Link href="/wallet" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Wallet & MetaMask</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">13-chain token import</div>
</Link>
<Link href="/routes" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Routes
<Link href="/liquidity" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Liquidity tools</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">LP policy bridge underlying, not LP</div>
</Link>
<Link href="/liquidity" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Liquidity
<Link href="/token-aggregation/api/v1/report/official-protocols" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Public JSON APIs</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Protocols, pools, routes</div>
</Link>
<Link href="/bridge" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Bridge
<Link href="/search" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Search</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Address, tx, mesh, pools</div>
</Link>
<Link href="/analytics" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
Analytics
<Link href="/docs" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Documentation</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">GRU, APIs, operations</div>
</Link>
<Link href="/bridge" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
<div className="text-sm font-semibold text-primary-600">Bridge</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Relay posture + CCIP lanes</div>
</Link>
</div>
</Card>

View File

@@ -0,0 +1,256 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { Card, Address } from '@/libs/frontend-ui-primitives'
import PageIntro from '@/components/common/PageIntro'
import EntityBadge from '@/components/common/EntityBadge'
import OperationsSurfaceNav from '@/components/explorer/OperationsSurfaceNav'
import LpPositionPanel from '@/components/wallet/LpPositionPanel'
import {
fetchPoolRegistry,
type CuratedPoolRegistryEntry,
} from '@/services/api/liquidityPositions'
import { useUiMode } from '@/components/common/UiModeContext'
function formatUsd(value: number | undefined): string {
if (value == null || !Number.isFinite(value)) return 'Unavailable'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: value >= 1000 ? 0 : 2,
}).format(value)
}
function formatReserve(raw: string | undefined, symbol: string): string {
if (!raw) return 'Unavailable'
const decimals = symbol.toUpperCase().includes('BTC') ? 8 : 6
try {
const value = BigInt(raw)
const scale = 10n ** BigInt(decimals)
const whole = value / scale
const fraction = value % scale
const fractionText = fraction
.toString()
.padStart(decimals, '0')
.slice(0, 4)
.replace(/0+$/, '')
return fractionText
? `${whole.toLocaleString()}.${fractionText} ${symbol}`
: `${whole.toLocaleString()} ${symbol}`
} catch {
return 'Unavailable'
}
}
function isStableLikeSymbol(symbol: string): boolean {
const normalized = symbol.toUpperCase().replace(/^C/, '')
return ['USDT', 'USDC', 'EURC', 'EURT', 'GBPC', 'DAI', 'BUSD', 'TUSD', 'FRAX'].includes(normalized)
}
function stableReserveAsymmetryNote(
pool: CuratedPoolRegistryEntry,
): { severity: 'warning' | 'info'; message: string; deviationBps: number } | null {
if (pool.reserve0Usd == null || pool.reserve1Usd == null) return null
if (!isStableLikeSymbol(pool.baseSymbol) || !isStableLikeSymbol(pool.quoteSymbol)) return null
const legs = [pool.reserve0Usd, pool.reserve1Usd].filter((v) => v > 0)
if (legs.length < 2) return null
const min = Math.min(...legs)
const max = Math.max(...legs)
const deviationBps = Math.round(((max - min) / max) * 10000)
if (deviationBps < 150) return null
return {
severity: deviationBps >= 500 ? 'warning' : 'info',
deviationBps,
message: `Stable-pair reserves are asymmetric (${deviationBps} bps USD leg gap). PMM pools can hold unequal notionals while remaining tradable — do not assume 50/50 peg depth for trade sizing.`,
}
}
interface PoolDetailPageProps {
poolAddress: string
}
export default function PoolDetailPage({ poolAddress }: PoolDetailPageProps) {
const { mode } = useUiMode()
const [pools, setPools] = useState<CuratedPoolRegistryEntry[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
let active = true
void fetchPoolRegistry()
.then((report) => {
if (!active) return
setPools(report?.pools ?? [])
})
.finally(() => {
if (active) setLoading(false)
})
return () => {
active = false
}
}, [])
const pool = useMemo(() => {
const lower = poolAddress.toLowerCase()
return pools.find(
(row) => row.poolAddress.toLowerCase() === lower || row.lpTokenAddress.toLowerCase() === lower,
)
}, [poolAddress, pools])
const pairLabel = pool ? `${pool.baseSymbol} / ${pool.quoteSymbol}` : poolAddress
const asymmetryNote = pool ? stableReserveAsymmetryNote(pool) : null
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
<OperationsSurfaceNav />
<PageIntro
eyebrow="Curated pool registry"
title={loading ? 'Loading pool…' : pool ? pairLabel : 'Pool not in registry'}
description={
mode === 'guided'
? 'Chain-local liquidity pool from the curated DODO PMM + UniV2 registry. LP receipt tokens are not bridgeable — bridge underlying c*/cW* instead.'
: 'Curated pool detail · LP is chain-local only.'
}
actions={[
{ href: '/liquidity', label: 'Liquidity tools' },
{ href: '/pools', label: 'All pools' },
{ href: '/wallet', label: 'Scan LP positions' },
]}
/>
{loading ? (
<Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Loading pool registry</p>
</Card>
) : null}
{!loading && !pool ? (
<Card title="Not in curated registry">
<p className="text-sm text-gray-600 dark:text-gray-400">
This address is not listed in the curated pool registry. It may still be a valid contract on Chain 138 or
another network open the address page for generic explorer data.
</p>
<div className="mt-4 flex flex-wrap gap-3">
<Link href={`/addresses/${poolAddress}`} className="text-primary-600 hover:underline">
Open address page
</Link>
<Link href="/search" className="text-primary-600 hover:underline">
Back to search
</Link>
</div>
</Card>
) : null}
{pool ? (
<div className="space-y-6">
{asymmetryNote ? (
<Card title="Reserve asymmetry notice">
<p
className={
asymmetryNote.severity === 'warning'
? 'text-sm text-amber-800 dark:text-amber-200'
: 'text-sm text-gray-700 dark:text-gray-300'
}
>
{asymmetryNote.message}
</p>
</Card>
) : null}
<Card title="Pool summary">
<div className="flex flex-wrap gap-2">
<EntityBadge label={pool.venue.replace('_', ' ')} tone="info" />
<EntityBadge label={pool.lpTokenType.replace('_', ' ')} tone="neutral" />
<EntityBadge label={`chain ${pool.chainId}`} tone="warning" />
{pool.integrationStack ? (
<EntityBadge label={pool.integrationStack} tone="success" className="normal-case tracking-normal" />
) : null}
</div>
<dl className="mt-4 grid gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-gray-500 dark:text-gray-400">Pool address</dt>
<dd className="mt-1">
<Address address={pool.poolAddress} truncate showCopy />
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">LP token</dt>
<dd className="mt-1">
<Address address={pool.lpTokenAddress} truncate showCopy />
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">Base</dt>
<dd className="mt-1">
<Link href={`/tokens/${pool.baseAddress}`} className="text-primary-600 hover:underline">
{pool.baseSymbol}
</Link>
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">Quote</dt>
<dd className="mt-1">
<Link href={`/tokens/${pool.quoteAddress}`} className="text-primary-600 hover:underline">
{pool.quoteSymbol}
</Link>
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">Est. liquidity (USD)</dt>
<dd className="mt-1 font-semibold text-gray-900 dark:text-white">
{formatUsd(pool.totalLiquidityUsd)}
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">Base reserve</dt>
<dd className="mt-1 text-gray-700 dark:text-gray-300">
{formatReserve(pool.reserve0, pool.baseSymbol)}
{pool.reserve0Usd != null && pool.reserve0Usd > 0 ? (
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
({formatUsd(pool.reserve0Usd)})
</span>
) : null}
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">Quote reserve</dt>
<dd className="mt-1 text-gray-700 dark:text-gray-300">
{formatReserve(pool.reserve1, pool.quoteSymbol)}
{pool.reserve1Usd != null && pool.reserve1Usd > 0 ? (
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
({formatUsd(pool.reserve1Usd)})
</span>
) : null}
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">Liquidity source</dt>
<dd className="mt-1 text-gray-700 dark:text-gray-300">
{pool.liquiditySource ?? 'curated registry'}
{pool.liquidityAsOf ? (
<span className="block text-xs text-gray-500 dark:text-gray-400">
As of {new Date(pool.liquidityAsOf).toLocaleString()}
</span>
) : null}
</dd>
</div>
<div>
<dt className="text-gray-500 dark:text-gray-400">Policy</dt>
<dd className="mt-1 text-gray-700 dark:text-gray-300">
LP shares are chain-local receipts not cross-chain mesh tokens.
</dd>
</div>
</dl>
</Card>
<LpPositionPanel
chainId={pool.chainId}
title="Your LP in this pool"
compact
hintAddresses={[pool.poolAddress, pool.lpTokenAddress]}
/>
</div>
) : null}
</div>
)
}

View File

@@ -4,6 +4,30 @@ import { useEffect, useMemo, useState } from 'react'
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
import { tokensApi } from '@/services/api/tokens'
import { selectWalletFeaturedTokens } from '@/utils/featuredTokens'
import MultiChainWalletImport from '@/components/wallet/MultiChainWalletImport'
import MobileWalletContextBanner from '@/components/wallet/MobileWalletContextBanner'
import WalletFundedTokenListing from '@/components/wallet/WalletFundedTokenListing'
import { getActiveWalletConnectProvider } from '@/services/wallet/walletConnectClient'
import { toWalletAddEthereumChainParams } from '@/utils/walletAddEthereumChain'
import {
isMobileWalletContext,
MOBILE_WALLET_BUTTON_CLASS,
resolveWalletEthereumProvider,
type EthereumProvider,
} from '@/utils/walletProviderEnv'
import { buildWatchAssetRpcRequest, runWatchAssetBatch } from '@/utils/walletWatchAsset'
import {
CHAIN138_PLACEHOLDER_GAS_SYMBOLS,
dedupeWalletWatchTokens,
isWalletWatchEligibleAddress,
} from '@/utils/walletWatchEligible'
import {
CHAIN138_NATIVE_ETH_LOGO,
formatFundedRowBalanceUsd,
fundedRowsToWatchCatalogTokens,
loadFundedWalletTokenListing,
type FundedWalletTokenRow,
} from '@/utils/walletFundedTokenListing'
export type WalletChain = {
chainId: string
@@ -117,6 +141,13 @@ export type FetchMetadata = {
lastModified?: string | null
}
type PendingWatchFlow = {
tokens: TokenListToken[]
label: string
nextIndex: number
totalAdded: number
}
interface AddToMetaMaskProps {
initialNetworks?: NetworksCatalog | null
initialTokenList?: TokenListCatalog | null
@@ -126,8 +157,10 @@ interface AddToMetaMaskProps {
initialCapabilitiesMeta?: FetchMetadata | null
}
type EthereumProvider = {
request: (args: { method: string; params?: unknown }) => Promise<unknown>
function chainParamsForWallet(chain: WalletChain) {
return toWalletAddEthereumChainParams(chain, {
preferSingleRpc: typeof window !== 'undefined' && isMobileWalletContext(),
})
}
const FALLBACK_CHAIN_138: WalletChain = {
@@ -135,7 +168,7 @@ const FALLBACK_CHAIN_138: WalletChain = {
chainIdDecimal: 138,
chainName: 'DeFi Oracle Meta Mainnet',
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
rpcUrls: ['https://rpc-http-pub.d-bis.org', 'https://rpc.d-bis.org', 'https://rpc2.d-bis.org'],
rpcUrls: ['https://rpc-http-pub.d-bis.org'],
blockExplorerUrls: ['https://explorer.d-bis.org', 'https://blockscout.defi-oracle.io'],
iconUrls: [
'https://explorer.d-bis.org/api/v1/report/logo/chain-138',
@@ -353,13 +386,36 @@ export function AddToMetaMask({
const [metamaskConfigMeta, setMetamaskConfigMeta] = useState<FetchMetadata | null>(null)
const [curatedTokens, setCuratedTokens] = useState<TokenListToken[]>([])
const [watchAssetProgress, setWatchAssetProgress] = useState<{ current: number; total: number } | null>(null)
const [balanceCheckProgress, setBalanceCheckProgress] = useState<{ current: number; total: number } | null>(null)
const [fundedTokenRows, setFundedTokenRows] = useState<FundedWalletTokenRow[]>([])
const [fundedListingWallet, setFundedListingWallet] = useState<string | null>(null)
const [fundedListingLoading, setFundedListingLoading] = useState(false)
const [fundedListingError, setFundedListingError] = useState<string | null>(null)
const [pendingWatchFlow, setPendingWatchFlow] = useState<PendingWatchFlow | null>(null)
const [providerTick, setProviderTick] = useState(0)
const ethereum = typeof window !== 'undefined'
? (window as unknown as { ethereum?: EthereumProvider }).ethereum
: undefined
const mobileWalletContext = typeof window !== 'undefined' && isMobileWalletContext()
const resolveEthereum = (): EthereumProvider | undefined => {
void providerTick
return resolveWalletEthereumProvider(getActiveWalletConnectProvider())
}
const hasWalletProvider = typeof window !== 'undefined' && Boolean(resolveEthereum())
useEffect(() => {
if (typeof window === 'undefined') return undefined
const refresh = () => setProviderTick((value) => value + 1)
window.addEventListener('ethereum#initialized', refresh)
window.addEventListener('focus', refresh)
return () => {
window.removeEventListener('ethereum#initialized', refresh)
window.removeEventListener('focus', refresh)
}
}, [])
const apiBase = getApiBase().replace(/\/$/, '')
const tokenListUrl = `${apiBase}/api/v1/report/token-list?chainId=138`
const tokenListUrl = `${apiBase}/api/v1/report/token-list?chainId=138&wallet=1`
const networksUrl = `${apiBase}/api/config/networks`
const metamaskConfigUrl = `${apiBase}/api/v1/config/metamask?chainId=138`
const capabilitiesUrl = `${apiBase}/api/config/capabilities`
@@ -476,7 +532,10 @@ export function AddToMetaMask({
}, [])
const catalogTokens = useMemo(
() => (Array.isArray(tokenList?.tokens) ? tokenList.tokens.filter(isTokenListToken) : []),
() =>
(Array.isArray(tokenList?.tokens) ? tokenList.tokens.filter(isTokenListToken) : []).filter((token) =>
isWalletWatchEligibleAddress(token.address),
),
[tokenList],
)
@@ -502,27 +561,31 @@ export function AddToMetaMask({
)
const watchAssetTokens = useMemo(() => {
const endpointTokens = (metamaskConfig?.watchAssets || [])
.filter(isWatchAssetEntry)
.map(watchAssetToToken)
const endpointTokens = dedupeWalletWatchTokens(
(metamaskConfig?.watchAssets || [])
.filter(isWatchAssetEntry)
.map(watchAssetToToken)
.filter((token) => isWalletWatchEligibleAddress(token.address)),
)
if (endpointTokens.length > 0) return endpointTokens
return catalogTokens.filter((token) => token.chainId === 138)
return dedupeWalletWatchTokens(catalogTokens.filter((token) => token.chainId === 138))
}, [catalogTokens, metamaskConfig])
const addChain = async (chain: WalletChain) => {
setError(null)
setStatus(null)
const ethereum = resolveEthereum()
if (!ethereum) {
setError('MetaMask or another Web3 wallet is not installed.')
setError('No wallet provider found. Open this page in MetaMask mobile, install a browser wallet, or connect WalletConnect above.')
return
}
try {
await ethereum.request({
method: 'wallet_addEthereumChain',
params: [chain],
params: [chainParamsForWallet(chain)],
})
setStatus(`Added ${chain.chainName}. You can switch to it in your wallet.`)
} catch (e) {
@@ -536,8 +599,9 @@ export function AddToMetaMask({
}
const switchOrAddChain = async (chain: WalletChain) => {
const ethereum = resolveEthereum()
if (!ethereum) {
setError('MetaMask or another Web3 wallet is not installed.')
setError('No wallet provider found. Open this page in MetaMask mobile, install a browser wallet, or connect WalletConnect above.')
return false
}
@@ -558,7 +622,7 @@ export function AddToMetaMask({
try {
await ethereum.request({
method: 'wallet_addEthereumChain',
params: [chain],
params: [chainParamsForWallet(chain)],
})
return true
} catch (e) {
@@ -572,8 +636,9 @@ export function AddToMetaMask({
setError(null)
setStatus(null)
const ethereum = resolveEthereum()
if (!ethereum) {
setError('MetaMask or another Web3 wallet is not installed.')
setError('No wallet provider found. Snaps require MetaMask in a supported browser.')
return
}
@@ -602,6 +667,13 @@ export function AddToMetaMask({
}
}
const networkForToken = (token: TokenListToken): WalletChain | null => {
if (token.chainId === 138) return chains.chain138
if (token.chainId === 1) return chains.ethereum
if (token.chainId === 651940) return chains.allMainnet
return null
}
const watchToken = async (token: TokenListToken) => {
setError(null)
setStatus(null)
@@ -611,32 +683,118 @@ export function AddToMetaMask({
return
}
if (!ethereum) {
setError('MetaMask or another Web3 wallet is not installed.')
if (!isWalletWatchEligibleAddress(token.address)) {
setError(
`${token.symbol} uses a roadmap placeholder address on Chain 138 (not a live ERC-20). Remove it from MetaMask if it was added earlier, then use Add all Chain 138 tokens for live contracts such as cUSDT, cUSDC, and cBTC.`,
)
return
}
try {
const added = await ethereum.request({
method: 'wallet_watchAsset',
params: {
type: 'ERC20',
options: {
address: token.address,
symbol: token.symbol,
decimals: token.decimals,
image: token.logoURI,
},
},
})
const ethereum = resolveEthereum()
if (!ethereum) {
setError('No wallet provider found. Open this page in MetaMask mobile, install a browser wallet, or connect WalletConnect above.')
return
}
setStatus(added ? `Added ${token.symbol} to your wallet.` : `${token.symbol} request was dismissed.`)
const network = networkForToken(token)
if (network) {
const switched = await switchOrAddChain(network)
if (!switched) return
}
try {
const added = await ethereum.request(buildWatchAssetRpcRequest(token, mobileWalletContext))
setStatus(
added
? `Added ${token.symbol} on ${network?.chainName || `chain ${token.chainId}`}. Switch to that network in MetaMask to see balances.`
: `${token.symbol} request was dismissed.`,
)
} catch (e) {
const err = e as { message?: string }
setError(err.message || `Failed to add ${token.symbol}.`)
}
}
const watchAssetCatalogTokens = useMemo(
() =>
watchAssetTokens.map((token) => ({
chainId: token.chainId,
address: token.address,
symbol: token.symbol,
name: token.name,
decimals: token.decimals,
logoURI: token.logoURI,
})),
[watchAssetTokens],
)
const requestConnectedWalletAddress = async (): Promise<string | null> => {
const ethereum = resolveEthereum()
if (!ethereum) {
setError('No wallet provider found. Open this page in MetaMask mobile, install a browser wallet, or connect WalletConnect above.')
return null
}
const switched = await switchOrAddChain(chains.chain138)
if (!switched) return null
try {
const accounts = (await ethereum.request({ method: 'eth_requestAccounts' })) as string[]
return accounts[0] || null
} catch (e) {
const err = e as { message?: string }
setError(err.message || 'Could not connect a wallet account.')
return null
}
}
const refreshFundedTokenListing = async () => {
setFundedListingError(null)
setFundedListingLoading(true)
setBalanceCheckProgress(null)
try {
const walletAddress = await requestConnectedWalletAddress()
if (!walletAddress) {
setFundedTokenRows([])
setFundedListingWallet(null)
return
}
const ethereum = resolveEthereum()
if (!ethereum) return
const rows = await loadFundedWalletTokenListing(
ethereum,
walletAddress,
watchAssetCatalogTokens,
{
nativeLogoUri: CHAIN138_NATIVE_ETH_LOGO,
onProgress: (current, total) => setBalanceCheckProgress({ current, total }),
},
)
setFundedTokenRows(rows)
setFundedListingWallet(walletAddress)
setBalanceCheckProgress(null)
const erc20Count = rows.filter((row) => row.kind === 'erc20').length
setStatus(
rows.length > 0
? `Loaded ${rows.length} funded holding(s) for ${walletAddress.slice(0, 6)}${walletAddress.slice(-4)} (${erc20Count} ERC-20 + native ETH when present).`
: `No funded catalog tokens for ${walletAddress.slice(0, 6)}${walletAddress.slice(-4)}.`,
)
} catch (e) {
const err = e as { message?: string }
setFundedListingError(err.message || 'Failed to load funded token listing.')
setFundedTokenRows([])
setFundedListingWallet(null)
} finally {
setFundedListingLoading(false)
setBalanceCheckProgress(null)
}
}
const refreshMainnetCwusdc = async () => {
setError(null)
setStatus(null)
@@ -647,51 +805,140 @@ export function AddToMetaMask({
await watchToken(MAINNET_CWUSDC_TOKEN)
}
const watchTokensSequentially = async (tokens: TokenListToken[], label: string) => {
const watchTokensSequentially = async (
tokens: TokenListToken[],
label: string,
startIndex = 0,
priorAdded = 0,
) => {
setError(null)
setStatus(null)
if (startIndex === 0) {
setStatus(null)
setPendingWatchFlow(null)
}
setWatchAssetProgress(null)
setBalanceCheckProgress(null)
const ethereum = resolveEthereum()
if (!ethereum) {
setError('MetaMask or another Web3 wallet is not installed.')
setError('No wallet provider found. Open this page in MetaMask mobile, install a browser wallet, or connect WalletConnect above.')
return
}
const validTokens = tokens.filter(isTokenListToken)
const validTokens = dedupeWalletWatchTokens(
tokens.filter(isTokenListToken).filter((token) => isWalletWatchEligibleAddress(token.address)),
)
if (validTokens.length === 0) {
setError('No live Chain 138 token metadata is available for wallet_watchAsset right now.')
return
}
const switched = await switchOrAddChain(chains.chain138)
if (!switched) return
const batch = await runWatchAssetBatch(ethereum, validTokens, startIndex, {
mobile: mobileWalletContext,
onProgress: (current, total) => setWatchAssetProgress({ current, total }),
})
const totalAdded = priorAdded + batch.addedCount
setWatchAssetProgress(null)
if (batch.stoppedEarly) {
setError(batch.errorMessage || 'Token import stopped.')
setPendingWatchFlow(null)
setStatus(`${totalAdded} of ${validTokens.length} ${label} token requests were accepted before the flow stopped.`)
return
}
if (batch.nextIndex < validTokens.length) {
setPendingWatchFlow({
tokens: validTokens,
label,
nextIndex: batch.nextIndex,
totalAdded,
})
setStatus(
`${totalAdded} of ${validTokens.length} ${label} tokens added. Tap Continue for the next ${Math.min(validTokens.length - batch.nextIndex, 2)} prompt(s). On mobile, approve each wallet popup before it closes.`,
)
return
}
setPendingWatchFlow(null)
setStatus(
`${totalAdded} of ${validTokens.length} ${label} token requests were accepted. Native ETH appears automatically on DeFi Oracle Meta Mainnet (not via Add Token). Stay on Chain 138 with RPC https://rpc-http-pub.d-bis.org to see balances.`,
)
}
const continuePendingWatchFlow = async () => {
if (!pendingWatchFlow) return
const { tokens, label, nextIndex, totalAdded } = pendingWatchFlow
await watchTokensSequentially(tokens, label, nextIndex, totalAdded)
}
const watchTokensWithBalanceOnly = async () => {
setError(null)
setStatus(null)
setWatchAssetProgress(null)
setBalanceCheckProgress(null)
setFundedListingError(null)
const ethereum = resolveEthereum()
if (!ethereum) {
setError('No wallet provider found. Open this page in MetaMask mobile, install a browser wallet, or connect WalletConnect above.')
return
}
const candidates = watchAssetTokens.filter(isTokenListToken)
if (candidates.length === 0) {
setError('No complete token metadata is available for wallet_watchAsset right now.')
return
}
let addedCount = 0
for (let index = 0; index < validTokens.length; index += 1) {
const token = validTokens[index]
setWatchAssetProgress({ current: index + 1, total: validTokens.length })
try {
const added = await ethereum.request({
method: 'wallet_watchAsset',
params: {
type: 'ERC20',
options: {
address: token.address,
symbol: token.symbol,
decimals: token.decimals,
image: token.logoURI,
},
},
})
if (added) addedCount += 1
} catch (e) {
const err = e as { message?: string }
setError(err.message || `Stopped while adding ${token.symbol}.`)
setStatus(`${addedCount} of ${validTokens.length} ${label} token requests were accepted before the flow stopped.`)
setWatchAssetProgress(null)
setFundedListingLoading(true)
try {
const walletAddress = await requestConnectedWalletAddress()
if (!walletAddress) return
setStatus(`Checking ERC-20 balances and market prices for ${candidates.length} catalog tokens on Chain 138…`)
const rows = await loadFundedWalletTokenListing(
ethereum,
walletAddress,
watchAssetCatalogTokens,
{
nativeLogoUri: CHAIN138_NATIVE_ETH_LOGO,
onProgress: (current, total) => setBalanceCheckProgress({ current, total }),
},
)
setFundedTokenRows(rows)
setFundedListingWallet(walletAddress)
setBalanceCheckProgress(null)
const fundedTokens = fundedRowsToWatchCatalogTokens(rows)
if (fundedTokens.length === 0) {
const nativeRow = rows.find((row) => row.kind === 'native')
setStatus(
nativeRow
? `Native ETH: ${nativeRow.balanceLabel} (${formatFundedRowBalanceUsd(nativeRow)}). No ERC-20 balances among catalog tokens — nothing to add via EIP-747.`
: `No ERC-20 balances found among ${candidates.length} catalog tokens for ${walletAddress.slice(0, 6)}${walletAddress.slice(-4)}.`,
)
return
}
}
setWatchAssetProgress(null)
setStatus(`${addedCount} of ${validTokens.length} ${label} token requests were accepted by the wallet.`)
const symbols = fundedTokens.map((token) => token.symbol).join(', ')
setStatus(
`Found ${fundedTokens.length} ERC-20 token(s) with balance (${symbols}). Native ETH is listed above when present. Starting wallet prompts…`,
)
await watchTokensSequentially(fundedTokens as TokenListToken[], 'funded Chain 138')
} catch (e) {
const err = e as { message?: string }
setError(err.message || 'Failed to read token balances from Chain 138.')
setBalanceCheckProgress(null)
} finally {
setFundedListingLoading(false)
}
}
const copyText = async (value: string, label: string) => {
@@ -727,25 +974,38 @@ export function AddToMetaMask({
metadata directly to the wallet.
</p>
<div className="grid gap-3 md:grid-cols-3">
<MobileWalletContextBanner hasProvider={hasWalletProvider} />
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() =>
void switchOrAddChain(chains.chain138).then((ok) => {
if (ok) setStatus('Switched to DeFi Oracle Meta Mainnet. Native ETH and imported tokens show on this network.')
})
}
className={`rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Switch to Chain 138
</button>
<button
type="button"
onClick={() => addChain(chains.chain138)}
className="rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
className={`rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add Chain 138
</button>
<button
type="button"
onClick={() => addChain(chains.ethereum)}
className="rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700"
className={`rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add Ethereum Mainnet
</button>
<button
type="button"
onClick={() => addChain(chains.allMainnet)}
className="rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700"
className={`rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add ALL Mainnet
</button>
@@ -893,29 +1153,84 @@ export function AddToMetaMask({
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
These tokens come from the explorer MetaMask payload and use wallet_watchAsset so the wallet gets the same
symbol, decimals, image, and optional token metadata that the explorer publishes. MetaMask requires a user
approval for each token, so the bulk actions below run as a guided sequence of wallet prompts.
approval for each token, so the bulk actions below run as a guided sequence of wallet prompts. Bulk add
switches to DeFi Oracle Meta Mainnet first tokens imported while on another network will not show
balances on Chain 138. Native ETH is not added via this flow; it appears when you are on Chain 138 with a
working RPC. Use <span className="font-medium">Add tokens with balance only</span> to skip zero-balance and
undeployed catalog entries.
{mobileWalletContext
? ' On mobile, imports run two wallet prompts per tap — use Continue until finished.'
: null}
</p>
<p className="mt-2 rounded-lg border border-amber-200 bg-amber-50/80 p-3 text-sm leading-6 text-amber-950 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-100">
MetaMask keeps <span className="font-medium">one row per token symbol</span> on a network. If a balance
disappears after import, remove duplicate custom tokens (especially second <code className="text-xs">cUSDC</code>{' '}
/ <code className="text-xs">cUSDT</code> rows or old gas placeholders like{' '}
{CHAIN138_PLACEHOLDER_GAS_SYMBOLS.slice(0, 3).join(', ')}), stay on{' '}
<span className="font-medium">DeFi Oracle Meta Mainnet</span>, then tap{' '}
<span className="font-medium">Add tokens with balance only</span> or{' '}
<span className="font-medium">Add featured tokens</span>.
</p>
<div className="mt-4 flex flex-wrap gap-2">
{pendingWatchFlow ? (
<button
type="button"
disabled={watchAssetProgress !== null || balanceCheckProgress !== null || fundedListingLoading}
onClick={() => void continuePendingWatchFlow()}
className={`rounded bg-amber-600 px-3 py-2 text-sm font-medium text-white hover:bg-amber-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Continue ({pendingWatchFlow.totalAdded} added, {pendingWatchFlow.tokens.length - pendingWatchFlow.nextIndex} left)
</button>
) : null}
<button
type="button"
disabled={watchAssetProgress !== null || balanceCheckProgress !== null || fundedListingLoading}
onClick={() => void watchTokensWithBalanceOnly()}
className={`rounded bg-emerald-600 px-3 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add tokens with balance only
</button>
<button
type="button"
disabled={watchAssetProgress !== null || balanceCheckProgress !== null || fundedListingLoading || pendingWatchFlow !== null}
onClick={() => void watchTokensSequentially(featuredTokens, 'featured Chain 138')}
className="rounded bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700"
className={`rounded bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add featured tokens
</button>
<button
type="button"
disabled={watchAssetProgress !== null || balanceCheckProgress !== null || fundedListingLoading || pendingWatchFlow !== null}
onClick={() => void watchTokensSequentially(watchAssetTokens, 'Chain 138')}
className="rounded bg-gray-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-gray-700"
className={`rounded bg-gray-600 px-3 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add all Chain 138 tokens
</button>
<button
type="button"
disabled={watchAssetProgress !== null || balanceCheckProgress !== null || fundedListingLoading}
onClick={() => void refreshFundedTokenListing()}
className={`rounded border border-emerald-600 px-3 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50 disabled:opacity-50 dark:text-emerald-300 dark:hover:bg-emerald-950/30 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Refresh funded holdings
</button>
{balanceCheckProgress ? (
<span className="self-center text-sm text-gray-600 dark:text-gray-400">
Balance check {balanceCheckProgress.current} of {balanceCheckProgress.total}
</span>
) : null}
{watchAssetProgress ? (
<span className="self-center text-sm text-gray-600 dark:text-gray-400">
Prompt {watchAssetProgress.current} of {watchAssetProgress.total}
</span>
) : null}
</div>
<WalletFundedTokenListing
rows={fundedTokenRows}
walletAddress={fundedListingWallet}
loading={fundedListingLoading}
error={fundedListingError}
/>
<div className="mt-4 space-y-3">
{featuredTokens.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">Featured token metadata is not available right now.</p>
@@ -940,7 +1255,7 @@ export function AddToMetaMask({
<button
type="button"
onClick={() => watchToken(token)}
className="rounded bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700"
className={`rounded bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add {token.symbol}
</button>
@@ -982,6 +1297,8 @@ export function AddToMetaMask({
{status ? <p className="text-sm text-green-600 dark:text-green-400">{status}</p> : null}
{error ? <p className="text-sm text-red-600 dark:text-red-400">{error}</p> : null}
<MultiChainWalletImport />
</div>
)
}

View File

@@ -0,0 +1,169 @@
'use client'
import { useEffect, useState } from 'react'
import Link from 'next/link'
import {
fetchLpPositions,
fetchPoolRegistry,
type LpPositionRow,
type PoolRegistryResponse,
} from '@/services/api/liquidityPositions'
import { chainLabel } from '@/utils/walletChainCatalog'
function formatUsd(value: number | null | undefined): string {
if (value == null || Number.isNaN(value)) return '—'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: value >= 1000 ? 0 : 2,
}).format(value)
}
function formatShare(value: number): string {
if (!Number.isFinite(value) || value <= 0) return '0%'
return `${(value * 100).toFixed(value < 0.01 ? 4 : 2)}%`
}
interface LpPositionPanelProps {
chainId?: number
address?: string | null
hintAddresses?: string[]
title?: string
compact?: boolean
}
export default function LpPositionPanel({
chainId = 138,
address,
hintAddresses = [],
title = 'LP positions',
compact = false,
}: LpPositionPanelProps) {
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [positions, setPositions] = useState<LpPositionRow[]>([])
const [totalUsd, setTotalUsd] = useState<number | null>(null)
const [registry, setRegistry] = useState<PoolRegistryResponse | null>(null)
const [notes, setNotes] = useState<string[]>([])
useEffect(() => {
void fetchPoolRegistry(chainId).then(setRegistry).catch(() => setRegistry(null))
}, [chainId])
useEffect(() => {
if (!address) {
setPositions([])
setTotalUsd(null)
setNotes([])
return
}
let active = true
setLoading(true)
setError(null)
void fetchLpPositions({ chainId, address, hintAddresses })
.then((report) => {
if (!active) return
if (!report) {
setError('LP position scan is unavailable right now.')
setPositions([])
setTotalUsd(null)
return
}
setPositions(report.positions)
setTotalUsd(report.totalEstimatedUsd)
setNotes(report.notes)
})
.catch(() => {
if (!active) return
setError('LP position scan failed.')
setPositions([])
setTotalUsd(null)
})
.finally(() => {
if (active) setLoading(false)
})
return () => {
active = false
}
}, [address, chainId, hintAddresses.join(',')])
return (
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 p-4 dark:border-gray-800 dark:bg-gray-900/40">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{title}</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Curated DODO PMM + UniV2 pools on {chainLabel(chainId)}
{registry ? ` · ${registry.count} pools in registry` : ''}
{address ? '' : ' · connect a wallet to scan LP shares'}
</div>
</div>
{totalUsd != null ? (
<div className="text-right">
<div className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Estimated LP NAV</div>
<div className="text-lg font-semibold text-gray-900 dark:text-white">{formatUsd(totalUsd)}</div>
</div>
) : null}
</div>
{loading ? (
<div className="mt-4 text-sm text-gray-600 dark:text-gray-400">Scanning on-chain LP balances</div>
) : null}
{error ? <div className="mt-4 text-sm text-red-600 dark:text-red-400">{error}</div> : null}
{!loading && !error && address && positions.length === 0 ? (
<div className="mt-4 text-sm text-gray-600 dark:text-gray-400">
No active LP shares detected in the curated pool registry for this address.
</div>
) : null}
{positions.length > 0 ? (
<div className={`mt-4 space-y-3 ${compact ? '' : ''}`}>
{positions.map((position) => (
<div
key={position.poolAddress}
className="rounded-xl border border-gray-200 bg-white/80 px-4 py-3 dark:border-gray-700 dark:bg-black/10"
>
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="font-semibold text-gray-900 dark:text-white">
{position.pairLabel}{' '}
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
({position.venue.replace('_', ' ')})
</span>
</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
Pool {position.poolAddress.slice(0, 10)}{position.poolAddress.slice(-8)} · share{' '}
{formatShare(position.shareOfPool)} · balance {position.shareBalanceUnits}
</div>
</div>
<div className="mt-2 flex flex-col items-end gap-1">
<div className="text-sm font-semibold text-gray-900 dark:text-white">
{formatUsd(position.estimatedUsd)}
</div>
<Link href={`/pools/${position.poolAddress}`} className="text-xs text-primary-600 hover:underline">
Pool detail
</Link>
<Link href={`/liquidity`} className="text-xs text-primary-600 hover:underline">
Liquidity tools
</Link>
</div>
</div>
</div>
))}
</div>
) : null}
{notes.length > 0 ? (
<div className="mt-4 space-y-1 text-xs text-gray-500 dark:text-gray-400">
{notes.map((note) => (
<p key={note}>{note}</p>
))}
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,49 @@
'use client'
import {
buildCoinbaseWalletDappUrl,
buildMetaMaskMobileDappUrl,
isMobileBrowser,
isWalletInAppBrowser,
MOBILE_WALLET_BUTTON_CLASS,
} from '@/utils/walletProviderEnv'
type MobileWalletContextBannerProps = {
hasProvider: boolean
}
export default function MobileWalletContextBanner({ hasProvider }: MobileWalletContextBannerProps) {
if (typeof window === 'undefined') return null
if (!isMobileBrowser() || isWalletInAppBrowser()) return null
const pageUrl = window.location.href
const metamaskUrl = buildMetaMaskMobileDappUrl(pageUrl)
const coinbaseUrl = buildCoinbaseWalletDappUrl(pageUrl)
return (
<div className="rounded-lg border border-sky-200 bg-sky-50/80 p-4 dark:border-sky-900/50 dark:bg-sky-950/30">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Mobile wallet browser</div>
<p className="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-400">
{hasProvider
? 'You are in a mobile browser with a wallet provider. Add-chain and add-token buttons work best one or two tokens at a time — use Continue when prompted. If a prompt does not appear, unlock your wallet and stay on this tab.'
: 'Mobile Safari/Chrome do not expose a wallet extension. Open this page inside MetaMask or Coinbase Wallet, or connect with WalletConnect above, then use the add-chain and add-token buttons.'}
</p>
{!hasProvider ? (
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap">
<a
href={metamaskUrl}
className={`inline-flex items-center justify-center rounded-lg bg-orange-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-orange-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Open in MetaMask
</a>
<a
href={coinbaseUrl}
className={`inline-flex items-center justify-center rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Open in Coinbase Wallet
</a>
</div>
) : null}
</div>
)
}

View File

@@ -0,0 +1,336 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
import type { TokenListToken, WalletChain } from '@/components/wallet/AddToMetaMask'
import { getActiveWalletConnectProvider } from '@/services/wallet/walletConnectClient'
import {
WALLET_FEATURED_SYMBOLS_BY_CHAIN,
WALLET_IMPORT_CHAIN_IDS,
chainLabel,
} from '@/utils/walletChainCatalog'
import { toWalletAddEthereumChainParams } from '@/utils/walletAddEthereumChain'
import {
isMobileWalletContext,
MOBILE_WALLET_BUTTON_CLASS,
resolveWalletEthereumProvider,
} from '@/utils/walletProviderEnv'
import { runWatchAssetBatch } from '@/utils/walletWatchAsset'
import { dedupeWalletWatchTokens } from '@/utils/walletWatchEligible'
type WatchAssetEntry = {
type: 'ERC20'
options: {
address: string
symbol: string
decimals: number
image?: string
}
}
type MetaMaskChainPayload = {
chainId?: number
addEthereumChain?: WalletChain
watchAssets?: WatchAssetEntry[]
}
type ChainImportState = {
chainId: number
network: WalletChain | null
tokens: TokenListToken[]
loading: boolean
error: string | null
}
type PendingMultiChainFlow = {
chainId: number
tokens: TokenListToken[]
label: string
nextIndex: number
totalAdded: number
}
function isTokenListToken(value: unknown): value is TokenListToken {
if (!value || typeof value !== 'object') return false
const candidate = value as Partial<TokenListToken>
return (
typeof candidate.chainId === 'number' &&
typeof candidate.address === 'string' &&
typeof candidate.symbol === 'string' &&
typeof candidate.decimals === 'number'
)
}
function watchAssetToToken(chainId: number, entry: WatchAssetEntry): TokenListToken {
return {
chainId,
address: entry.options.address,
symbol: entry.options.symbol,
name: entry.options.symbol,
decimals: entry.options.decimals,
logoURI: entry.options.image,
}
}
function getApiBase() {
return resolveExplorerApiBase({
browserOrigin: '',
serverFallback: 'https://explorer.d-bis.org',
}).replace(/\/$/, '')
}
function chainParamsForWallet(chain: WalletChain) {
return toWalletAddEthereumChainParams(chain, {
preferSingleRpc: typeof window !== 'undefined' && isMobileWalletContext(),
})
}
export default function MultiChainWalletImport() {
const [status, setStatus] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [progress, setProgress] = useState<{ current: number; total: number; chainId: number } | null>(null)
const [chains, setChains] = useState<ChainImportState[]>([])
const [pendingFlow, setPendingFlow] = useState<PendingMultiChainFlow | null>(null)
const mobileWalletContext = typeof window !== 'undefined' && isMobileWalletContext()
const resolveEthereum = () => resolveWalletEthereumProvider(getActiveWalletConnectProvider())
useEffect(() => {
let active = true
const apiBase = getApiBase()
async function loadChain(chainId: number): Promise<ChainImportState> {
try {
const [networkRes, metamaskRes] = await Promise.all([
fetch(`${apiBase}/api/config/networks`, { cache: 'no-store' }),
fetch(`${apiBase}/api/v1/config/metamask?chainId=${chainId}`, { cache: 'no-store' }),
])
const networksJson = networkRes.ok ? await networkRes.json() : null
const metamaskJson = metamaskRes.ok ? await metamaskRes.json() : null
const networkList = Array.isArray(networksJson?.chains) ? networksJson.chains : []
const network =
(metamaskJson as MetaMaskChainPayload | null)?.addEthereumChain ||
networkList.find((row: WalletChain) => row.chainIdDecimal === chainId) ||
null
const watchAssets = Array.isArray((metamaskJson as MetaMaskChainPayload | null)?.watchAssets)
? ((metamaskJson as MetaMaskChainPayload).watchAssets ?? [])
: []
let tokens = watchAssets.map((entry) => watchAssetToToken(chainId, entry))
if (tokens.length === 0) {
const listRes = await fetch(`${apiBase}/api/v1/report/token-list?chainId=${chainId}`, { cache: 'no-store' })
const listJson = listRes.ok ? await listRes.json() : null
tokens = (Array.isArray(listJson?.tokens) ? listJson.tokens : []).filter(isTokenListToken)
}
return { chainId, network, tokens, loading: false, error: null }
} catch (e) {
const message = e instanceof Error ? e.message : 'Failed to load chain metadata'
return { chainId, network: null, tokens: [], loading: false, error: message }
}
}
void (async () => {
const initial = WALLET_IMPORT_CHAIN_IDS.map((chainId) => ({
chainId,
network: null,
tokens: [],
loading: true,
error: null,
}))
if (active) setChains(initial)
const loaded = await Promise.all(WALLET_IMPORT_CHAIN_IDS.map((chainId) => loadChain(chainId)))
if (active) setChains(loaded)
})()
return () => {
active = false
}
}, [])
const featuredByChain = useMemo(() => {
return new Map(
chains.map((row) => {
const featuredSymbols = new Set(WALLET_FEATURED_SYMBOLS_BY_CHAIN[row.chainId] ?? [])
const featured = row.tokens.filter((token) => featuredSymbols.has(token.symbol))
return [row.chainId, featured.length > 0 ? featured : row.tokens.slice(0, 8)]
}),
)
}, [chains])
const switchOrAddChain = async (network: WalletChain) => {
const ethereum = resolveEthereum()
if (!ethereum) {
setError('No wallet provider found. Use MetaMask mobile in-app browser or WalletConnect.')
return false
}
try {
await ethereum.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: network.chainId }] })
return true
} catch (e) {
const err = e as { code?: number; message?: string }
if (err.code !== 4902) {
setError(err.message || `Failed to switch to ${network.chainName}.`)
return false
}
}
try {
await ethereum.request({ method: 'wallet_addEthereumChain', params: [chainParamsForWallet(network)] })
return true
} catch (e) {
const err = e as { message?: string }
setError(err.message || `Failed to add ${network.chainName}.`)
return false
}
}
const watchTokensSequentially = async (
chainId: number,
tokens: TokenListToken[],
label: string,
startIndex = 0,
priorAdded = 0,
) => {
setError(null)
if (startIndex === 0) {
setStatus(null)
setPendingFlow(null)
}
setProgress(null)
const ethereum = resolveEthereum()
if (!ethereum) {
setError('No wallet provider found. Use MetaMask mobile in-app browser or WalletConnect.')
return
}
const row = chains.find((entry) => entry.chainId === chainId)
if (!row?.network) {
setError(`Network metadata for ${chainLabel(chainId)} is not available yet.`)
return
}
const switched = await switchOrAddChain(row.network)
if (!switched) return
const validTokens = dedupeWalletWatchTokens(tokens.filter(isTokenListToken))
if (validTokens.length === 0) {
setError(`No token metadata is available for ${chainLabel(chainId)}.`)
return
}
const batch = await runWatchAssetBatch(ethereum, validTokens, startIndex, {
mobile: mobileWalletContext,
onProgress: (current, total) => setProgress({ current, total, chainId }),
})
const totalAdded = priorAdded + batch.addedCount
setProgress(null)
if (batch.stoppedEarly) {
setError(batch.errorMessage || 'Token import stopped.')
setPendingFlow(null)
setStatus(`${totalAdded} of ${validTokens.length} ${label} requests were accepted before the flow stopped.`)
return
}
if (batch.nextIndex < validTokens.length) {
setPendingFlow({
chainId,
tokens: validTokens,
label,
nextIndex: batch.nextIndex,
totalAdded,
})
setStatus(
`${totalAdded} of ${validTokens.length} on ${chainLabel(chainId)}. Tap Continue on that chain card for the next mobile prompts.`,
)
return
}
setPendingFlow(null)
setStatus(`${totalAdded} of ${validTokens.length} ${label} token requests were accepted on ${chainLabel(chainId)}.`)
}
const continuePendingFlow = async () => {
if (!pendingFlow) return
const { chainId, tokens, label, nextIndex, totalAdded } = pendingFlow
await watchTokensSequentially(chainId, tokens, label, nextIndex, totalAdded)
}
return (
<div className="mt-6 space-y-4 rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 sm:p-5">
<h2 className="text-lg font-semibold">Multi-chain token import</h2>
<p className="text-sm text-gray-600 dark:text-gray-400">
Add canonical tokens on other supported networks. Each chain switches your wallet first, then runs sequential
EIP-747 <code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">wallet_watchAsset</code> prompts.
{mobileWalletContext ? ' On mobile, two prompts run per tap — use Continue on the chain card.' : null}
</p>
{pendingFlow ? (
<button
type="button"
onClick={() => void continuePendingFlow()}
className={`rounded bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Continue {chainLabel(pendingFlow.chainId)} ({pendingFlow.totalAdded} added, {pendingFlow.tokens.length - pendingFlow.nextIndex} left)
</button>
) : null}
<div className="grid gap-4 xl:grid-cols-2">
{chains.map((row) => {
const featured = featuredByChain.get(row.chainId) ?? []
return (
<div key={row.chainId} className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="text-sm font-semibold text-gray-900 dark:text-white">{chainLabel(row.chainId)}</div>
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
{row.loading
? 'Loading token metadata…'
: row.error
? row.error
: `${row.tokens.length} canonical tokens · ${featured.length} featured`}
</div>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
disabled={!row.network || row.loading}
onClick={() => row.network && void switchOrAddChain(row.network).then((ok) => ok && setStatus(`Switched to ${chainLabel(row.chainId)}.`))}
className={`rounded bg-gray-600 px-3 py-2 text-xs font-medium text-white hover:bg-gray-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Switch chain
</button>
<button
type="button"
disabled={row.loading || featured.length === 0 || pendingFlow !== null}
onClick={() => void watchTokensSequentially(row.chainId, featured, `featured ${chainLabel(row.chainId)}`)}
className={`rounded bg-primary-600 px-3 py-2 text-xs font-medium text-white hover:bg-primary-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add featured
</button>
<button
type="button"
disabled={row.loading || row.tokens.length === 0 || pendingFlow !== null}
onClick={() => void watchTokensSequentially(row.chainId, row.tokens, chainLabel(row.chainId))}
className={`rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-800 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
Add all
</button>
</div>
</div>
{progress?.chainId === row.chainId ? (
<div className="mt-3 text-xs text-gray-600 dark:text-gray-400">
Prompt {progress.current} of {progress.total}
</div>
) : null}
</div>
)
})}
</div>
{status ? <p className="text-sm text-green-600 dark:text-green-400">{status}</p> : null}
{error ? <p className="text-sm text-red-600 dark:text-red-400">{error}</p> : null}
</div>
)
}

View File

@@ -0,0 +1,116 @@
import type { FundedWalletTokenRow } from '@/utils/walletFundedTokenListing'
import {
formatFundedRowBalanceUsd,
formatFundedRowUnitPrice,
} from '@/utils/walletFundedTokenListing'
function formatLiquidityUsd(value: number | undefined): string {
if (value == null || !Number.isFinite(value)) return '—'
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
maximumFractionDigits: value >= 100 ? 0 : 2,
}).format(value)
}
type WalletFundedTokenListingProps = {
rows: FundedWalletTokenRow[]
walletAddress?: string | null
loading?: boolean
error?: string | null
}
export default function WalletFundedTokenListing({
rows,
walletAddress,
loading = false,
error = null,
}: WalletFundedTokenListingProps) {
const erc20Count = rows.filter((row) => row.kind === 'erc20').length
const hasNative = rows.some((row) => row.kind === 'native')
return (
<div className="mt-4 rounded-lg border border-emerald-200 bg-emerald-50/40 p-4 dark:border-emerald-900/50 dark:bg-emerald-950/20">
<div className="text-sm font-semibold text-gray-900 dark:text-white">Funded Chain 138 holdings</div>
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Native ETH appears first with the canonical ETH logo. ERC-20 rows use explorer market-batch pricing (unit price,
balance USD, visible liquidity). MetaMask may still show &quot;-&quot; for custom tokens until upstream price
providers list Chain 138 assets.
</p>
{walletAddress ? (
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
Wallet {walletAddress.slice(0, 6)}{walletAddress.slice(-4)} · {hasNative ? 'ETH + ' : ''}
{erc20Count} ERC-20{erc20Count === 1 ? '' : 's'} with balance
</p>
) : null}
{loading ? (
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">Loading on-chain balances and market prices</p>
) : null}
{error ? <p className="mt-3 text-sm text-red-700 dark:text-red-300">{error}</p> : null}
{!loading && rows.length === 0 && !error ? (
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">
No funded catalog tokens found yet. Connect MetaMask and refresh this list.
</p>
) : null}
{rows.length > 0 ? (
<div className="mt-4 overflow-x-auto">
<table className="min-w-full text-left text-sm">
<thead>
<tr className="border-b border-emerald-200/80 text-xs uppercase tracking-wide text-gray-500 dark:border-emerald-900/60 dark:text-gray-400">
<th className="px-2 py-2 font-semibold">Token</th>
<th className="px-2 py-2 font-semibold">Balance</th>
<th className="px-2 py-2 font-semibold">Unit price</th>
<th className="px-2 py-2 font-semibold">Balance USD</th>
<th className="px-2 py-2 font-semibold">Liquidity</th>
<th className="px-2 py-2 font-semibold">MetaMask</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={row.kind === 'native' ? 'native-eth' : row.address!}
className="border-b border-emerald-100/80 dark:border-emerald-900/40"
>
<td className="px-2 py-3">
<div className="flex items-center gap-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={row.logoURI}
alt=""
width={32}
height={32}
className="h-8 w-8 rounded-full border border-gray-200 bg-white object-contain dark:border-gray-700"
/>
<div>
<div className="font-semibold text-gray-900 dark:text-white">
{row.symbol}
{row.kind === 'native' ? (
<span className="ml-2 text-xs font-normal text-emerald-700 dark:text-emerald-300">
native
</span>
) : null}
</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{row.name}</div>
</div>
</div>
</td>
<td className="px-2 py-3 font-medium text-gray-900 dark:text-white">{row.balanceLabel}</td>
<td className="px-2 py-3 text-gray-700 dark:text-gray-300">{formatFundedRowUnitPrice(row)}</td>
<td className="px-2 py-3 text-gray-700 dark:text-gray-300">{formatFundedRowBalanceUsd(row)}</td>
<td className="px-2 py-3 text-gray-700 dark:text-gray-300">{formatLiquidityUsd(row.liquidityUsd)}</td>
<td className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400">
{row.kind === 'native' ? 'Automatic on Chain 138' : 'EIP-747 add'}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</div>
)
}

View File

@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import LpPositionPanel from '@/components/wallet/LpPositionPanel'
import type {
CapabilitiesCatalog,
FetchMetadata,
@@ -29,6 +30,7 @@ import {
toggleWatchlistEntry,
writeWatchlistToStorage,
} from '@/utils/watchlist'
import { MOBILE_WALLET_BUTTON_CLASS } from '@/utils/walletProviderEnv'
interface WalletPageProps {
initialNetworks?: NetworksCatalog | null
@@ -142,6 +144,11 @@ export default function WalletPage(props: WalletPageProps) {
? isWatchlistEntry(watchlistEntries, walletSession.address)
: false
const lpHintAddresses = useMemo(
() => tokenBalances.map((balance) => balance.token_address).filter(Boolean),
[tokenBalances],
)
const loadWalletSnapshot = useCallback(async (address: string) => {
const [infoResponse, transactionsResponse, balancesResponse, transfersResponse] = await Promise.all([
addressesApi.getSafe(138, address),
@@ -310,7 +317,7 @@ export default function WalletPage(props: WalletPageProps) {
type="button"
onClick={() => void handleConnectWallet()}
disabled={connectingWallet}
className="rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-70"
className={`rounded-lg bg-primary-600 px-4 py-3 text-sm font-semibold text-white hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-70 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
{connectingWallet ? 'Connecting wallet…' : 'Connect wallet'}
</button>
@@ -323,7 +330,7 @@ export default function WalletPage(props: WalletPageProps) {
? 'Pair a mobile wallet via WalletConnect QR'
: 'Set WALLETCONNECT_PROJECT_ID on the explorer backend to enable WalletConnect'
}
className="rounded-lg border border-indigo-300 px-3 py-2 text-sm font-semibold text-indigo-700 hover:bg-indigo-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-60 dark:border-indigo-800 dark:text-indigo-300 dark:hover:bg-indigo-950/20"
className={`inline-flex items-center justify-center rounded-lg border border-indigo-300 px-4 py-3 text-sm font-semibold text-indigo-700 hover:bg-indigo-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-60 dark:border-indigo-800 dark:text-indigo-300 dark:hover:bg-indigo-950/20 ${MOBILE_WALLET_BUTTON_CLASS}`}
>
{connectingWalletConnect ? 'Opening WalletConnect…' : 'WalletConnect'}
</button>
@@ -518,6 +525,15 @@ export default function WalletPage(props: WalletPageProps) {
</div>
</div>
</div>
<div className="mt-6">
<LpPositionPanel
chainId={138}
address={walletSession.address}
hintAddresses={lpHintAddresses}
title="LP positions (Chain 138)"
/>
</div>
</div>
) : null}
</div>