feat(explorer): dual-chain wallet metadata, native coin pricing, and UI refresh.
Add Chain 138 wallet network metadata and stats coin-price enrichment; sync frontend explorer SPA, command center, and address/token pages with backend config. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,7 @@ import type { ReactNode } from 'react'
|
||||
import Navbar from './Navbar'
|
||||
import Footer from './Footer'
|
||||
import ExplorerAgentTool from './ExplorerAgentTool'
|
||||
import ExplorerDocumentHead from './ExplorerDocumentHead'
|
||||
import { UiModeProvider } from './UiModeContext'
|
||||
import { PostureGlossaryProvider } from './PostureGlossaryProvider'
|
||||
|
||||
@@ -9,6 +10,7 @@ export default function ExplorerChrome({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<UiModeProvider>
|
||||
<PostureGlossaryProvider>
|
||||
<ExplorerDocumentHead />
|
||||
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
|
||||
<a
|
||||
href="#main-content"
|
||||
|
||||
56
frontend/src/components/common/ExplorerDocumentHead.tsx
Normal file
56
frontend/src/components/common/ExplorerDocumentHead.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client'
|
||||
|
||||
import Head from 'next/head'
|
||||
import { useRouter } from 'next/router'
|
||||
|
||||
const BASE_TITLE = 'DBIS Explorer'
|
||||
|
||||
function resolveDocumentTitle(pathname: string, query: Record<string, string | string[] | undefined>): string {
|
||||
if (pathname === '/') return `${BASE_TITLE} — Chain 138`
|
||||
if (pathname === '/search') return `Search — ${BASE_TITLE}`
|
||||
if (pathname === '/wallet') return `Wallet Tools — ${BASE_TITLE}`
|
||||
if (pathname === '/protocols') return `Official Protocol Contracts — ${BASE_TITLE}`
|
||||
if (pathname.startsWith('/protocols/')) {
|
||||
const id = typeof query.id === 'string' ? query.id : pathname.split('/').pop() || 'Protocol'
|
||||
return `${id} — Protocols — ${BASE_TITLE}`
|
||||
}
|
||||
if (pathname === '/pools') return `Pool Registry — ${BASE_TITLE}`
|
||||
if (pathname.startsWith('/pools/')) return `Pool Detail — ${BASE_TITLE}`
|
||||
if (pathname === '/liquidity') return `Liquidity — ${BASE_TITLE}`
|
||||
if (pathname === '/operations') return `Operations — ${BASE_TITLE}`
|
||||
if (pathname === '/docs') return `Documentation — ${BASE_TITLE}`
|
||||
if (pathname === '/tokens') return `Tokens — ${BASE_TITLE}`
|
||||
if (pathname.startsWith('/tokens/')) return `Token — ${BASE_TITLE}`
|
||||
if (pathname === '/addresses') return `Addresses — ${BASE_TITLE}`
|
||||
if (pathname.startsWith('/addresses/')) return `Address — ${BASE_TITLE}`
|
||||
if (pathname === '/blocks') return `Blocks — ${BASE_TITLE}`
|
||||
if (pathname === '/transactions') return `Transactions — ${BASE_TITLE}`
|
||||
if (pathname.startsWith('/transactions/')) return `Transaction — ${BASE_TITLE}`
|
||||
if (pathname === '/bridge') return `Bridge — ${BASE_TITLE}`
|
||||
if (pathname === '/routes') return `Routes — ${BASE_TITLE}`
|
||||
if (pathname === '/analytics') return `Analytics — ${BASE_TITLE}`
|
||||
if (pathname === '/operator') return `Operator — ${BASE_TITLE}`
|
||||
if (pathname === '/system') return `System — ${BASE_TITLE}`
|
||||
if (pathname === '/weth') return `WETH — ${BASE_TITLE}`
|
||||
if (pathname === '/watchlist') return `Watchlist — ${BASE_TITLE}`
|
||||
if (pathname === '/access') return `Account Access — ${BASE_TITLE}`
|
||||
|
||||
const segment = pathname.split('/').filter(Boolean)[0]
|
||||
if (segment) {
|
||||
const label = segment.charAt(0).toUpperCase() + segment.slice(1)
|
||||
return `${label} — ${BASE_TITLE}`
|
||||
}
|
||||
return BASE_TITLE
|
||||
}
|
||||
|
||||
export default function ExplorerDocumentHead() {
|
||||
const router = useRouter()
|
||||
const pathname = router.pathname || '/'
|
||||
const title = resolveDocumentTitle(pathname, router.query)
|
||||
|
||||
return (
|
||||
<Head>
|
||||
<title>{title}</title>
|
||||
</Head>
|
||||
)
|
||||
}
|
||||
@@ -47,6 +47,7 @@ export default function Footer() {
|
||||
<li><Link className={footerLinkClass} href="/routes">Routes</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/liquidity">Liquidity</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/pools">Pools</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/protocols">Protocols</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/analytics">Analytics</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/operator">Operator</Link></li>
|
||||
<li><Link className={footerLinkClass} href="/system">System</Link></li>
|
||||
@@ -87,9 +88,9 @@ export default function Footer() {
|
||||
</p>
|
||||
<p>
|
||||
Command center:{' '}
|
||||
<a className={footerLinkClass} href="/chain138-command-center.html" target="_blank" rel="noopener noreferrer">
|
||||
<Link className={footerLinkClass} href="/topology">
|
||||
Chain 138 visual map
|
||||
</a>
|
||||
</Link>
|
||||
</p>
|
||||
<p className="text-xs leading-5 text-gray-500 dark:text-gray-500">
|
||||
Questions about the explorer, chain metadata, route discovery, or liquidity access
|
||||
|
||||
@@ -490,6 +490,7 @@ export default function Navbar() {
|
||||
pathname.startsWith('/tokens') ||
|
||||
pathname.startsWith('/analytics') ||
|
||||
pathname.startsWith('/pools') ||
|
||||
pathname.startsWith('/protocols') ||
|
||||
pathname.startsWith('/watchlist')
|
||||
const isOperationsActive =
|
||||
pathname.startsWith('/bridge') ||
|
||||
@@ -593,6 +594,7 @@ export default function Navbar() {
|
||||
{ href: '/tokens', label: 'Tokens', description: 'Review curated assets, standards, and token detail pages.' },
|
||||
{ href: '/analytics', label: 'Analytics', description: 'Open explorer-visible transaction and block activity summaries.' },
|
||||
{ href: '/pools', label: 'Pools', description: 'Browse mission-control pool inventory and route-backed liquidity context.' },
|
||||
{ href: '/protocols', label: 'Protocols', description: 'Review official upstream protocol contracts and production guardrails on Chain 138.' },
|
||||
{ href: '/watchlist', label: 'Watchlist', description: 'Jump into tracked addresses and saved explorer entities.' },
|
||||
],
|
||||
[],
|
||||
@@ -603,10 +605,11 @@ export default function Navbar() {
|
||||
{ href: '/bridge', label: 'Bridge', description: 'Inspect relay lanes, queue posture, and bridge trace tooling.' },
|
||||
{ href: '/routes', label: 'Routes', description: 'Review live route coverage, same-chain lanes, and bridge paths.' },
|
||||
{ href: '/liquidity', label: 'Liquidity', description: 'Check planner-backed route access and live liquidity posture.' },
|
||||
{ href: '/protocols', label: 'Protocols', description: 'Official DODO, UniV2/V3, CCIP, and integration contracts on Chain 138.' },
|
||||
{ href: '/system', label: 'System', description: 'Inspect topology, RPC capability, and public integration inventory.' },
|
||||
{ href: '/operator', label: 'Operator', description: 'Open planner, route, and relay shortcuts in one public page.' },
|
||||
{ href: '/weth', label: 'WETH', description: 'Review wrapped-asset references and bridge-oriented WETH context.' },
|
||||
{ href: '/chain138-command-center.html', label: 'Command Center', description: 'Open the visual command-center reference.', external: true },
|
||||
{ href: '/topology', label: 'Command Center', description: 'Open the visual topology and architecture map.' },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
||||
@@ -1,60 +1,97 @@
|
||||
import { buildPaginationItems } from '@/utils/pagination'
|
||||
|
||||
interface PaginationControlsProps {
|
||||
page: number
|
||||
pageCount: number
|
||||
onPageChange: (page: number) => void
|
||||
label?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
ariaLabel?: string
|
||||
/** Known total pages for bounded pagination. Omit for cursor-style list pagination. */
|
||||
pageCount?: number
|
||||
/** Used when pageCount is omitted. */
|
||||
hasNextPage?: boolean
|
||||
}
|
||||
|
||||
const buttonClassName =
|
||||
'rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300'
|
||||
|
||||
const activeButtonClassName = 'rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white'
|
||||
|
||||
export default function PaginationControls({
|
||||
page,
|
||||
pageCount,
|
||||
hasNextPage = false,
|
||||
onPageChange,
|
||||
label = 'Rows',
|
||||
className = '',
|
||||
disabled = false,
|
||||
ariaLabel,
|
||||
}: PaginationControlsProps) {
|
||||
if (pageCount <= 1) return null
|
||||
const isBounded = typeof pageCount === 'number'
|
||||
const boundedPageCount = pageCount ?? 1
|
||||
|
||||
const pages = Array.from({ length: pageCount }, (_, index) => index + 1)
|
||||
if (isBounded && boundedPageCount <= 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isBounded && page <= 1 && !hasNextPage) {
|
||||
return null
|
||||
}
|
||||
|
||||
const paginationItems = isBounded ? buildPaginationItems(page, boundedPageCount) : []
|
||||
const canGoPrevious = page > 1
|
||||
const canGoNext = isBounded ? page < boundedPageCount : hasNextPage
|
||||
const navLabel = ariaLabel || `${label} pagination`
|
||||
const statusText = isBounded ? `${label}: page ${page} of ${boundedPageCount}` : `${label}: page ${page}`
|
||||
|
||||
return (
|
||||
<div className={`mt-4 flex flex-wrap items-center justify-between gap-3 ${className}`}>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{label}: page {page} of {pageCount}
|
||||
</div>
|
||||
<nav
|
||||
className={`mt-4 flex flex-wrap items-center justify-between gap-3 ${className}`}
|
||||
aria-label={navLabel}
|
||||
>
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">{statusText}</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(Math.max(1, page - 1))}
|
||||
disabled={page <= 1}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
disabled={disabled || !canGoPrevious}
|
||||
className={buttonClassName}
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
{pages.map((candidate) => (
|
||||
<button
|
||||
key={candidate}
|
||||
type="button"
|
||||
onClick={() => onPageChange(candidate)}
|
||||
aria-current={candidate === page ? 'page' : undefined}
|
||||
className={
|
||||
candidate === page
|
||||
? 'rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white'
|
||||
: 'rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300'
|
||||
}
|
||||
>
|
||||
{candidate}
|
||||
</button>
|
||||
))}
|
||||
{isBounded ? (
|
||||
<ul className="flex flex-wrap items-center gap-2">
|
||||
{paginationItems.map((item) =>
|
||||
item.type === 'ellipsis' ? (
|
||||
<li key={item.key} className="px-1 text-sm text-gray-500 dark:text-gray-400" aria-hidden="true">
|
||||
…
|
||||
</li>
|
||||
) : (
|
||||
<li key={item.page}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(item.page)}
|
||||
disabled={disabled}
|
||||
aria-current={item.page === page ? 'page' : undefined}
|
||||
className={item.page === page ? activeButtonClassName : buttonClassName}
|
||||
>
|
||||
{item.page}
|
||||
</button>
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onPageChange(Math.min(pageCount, page + 1))}
|
||||
disabled={page >= pageCount}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300"
|
||||
onClick={() => onPageChange(isBounded ? Math.min(boundedPageCount, page + 1) : page + 1)}
|
||||
disabled={disabled || !canGoNext}
|
||||
className={buttonClassName}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useCallback, useId } from 'react'
|
||||
|
||||
export interface SectionTab<T extends string> {
|
||||
id: T
|
||||
label: string
|
||||
@@ -9,6 +11,21 @@ interface SectionTabsProps<T extends string> {
|
||||
activeTab: T
|
||||
onChange: (tab: T) => void
|
||||
className?: string
|
||||
idPrefix?: string
|
||||
ariaLabel?: string
|
||||
}
|
||||
|
||||
export function sectionTabPanelProps<T extends string>(
|
||||
idPrefix: string,
|
||||
tabId: T,
|
||||
activeTab: T,
|
||||
) {
|
||||
return {
|
||||
id: `${idPrefix}-panel-${tabId}`,
|
||||
role: 'tabpanel' as const,
|
||||
'aria-labelledby': `${idPrefix}-tab-${tabId}`,
|
||||
hidden: activeTab !== tabId,
|
||||
}
|
||||
}
|
||||
|
||||
export default function SectionTabs<T extends string>({
|
||||
@@ -16,29 +33,87 @@ export default function SectionTabs<T extends string>({
|
||||
activeTab,
|
||||
onChange,
|
||||
className = '',
|
||||
idPrefix,
|
||||
ariaLabel = 'Sections',
|
||||
}: SectionTabsProps<T>) {
|
||||
const generatedId = useId().replace(/:/g, '')
|
||||
const tabListPrefix = idPrefix || generatedId
|
||||
|
||||
const focusTab = useCallback(
|
||||
(tabId: T) => {
|
||||
const element = document.getElementById(`${tabListPrefix}-tab-${tabId}`)
|
||||
element?.focus()
|
||||
},
|
||||
[tabListPrefix],
|
||||
)
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const currentIndex = tabs.findIndex((tab) => tab.id === activeTab)
|
||||
if (currentIndex < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
let nextIndex = currentIndex
|
||||
|
||||
if (event.key === 'ArrowRight') {
|
||||
nextIndex = (currentIndex + 1) % tabs.length
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
nextIndex = (currentIndex - 1 + tabs.length) % tabs.length
|
||||
} else if (event.key === 'Home') {
|
||||
nextIndex = 0
|
||||
} else if (event.key === 'End') {
|
||||
nextIndex = tabs.length - 1
|
||||
} else {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
const nextTab = tabs[nextIndex]
|
||||
onChange(nextTab.id)
|
||||
focusTab(nextTab.id)
|
||||
},
|
||||
[activeTab, focusTab, onChange, tabs],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className={`sticky top-0 z-20 border-b border-gray-200 bg-white/95 py-3 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 ${className}`}>
|
||||
<div className="flex gap-2 overflow-x-auto">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={
|
||||
activeTab === tab.id
|
||||
? 'whitespace-nowrap rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white'
|
||||
: 'whitespace-nowrap rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300'
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
{typeof tab.count === 'number' ? (
|
||||
<span className={activeTab === tab.id ? 'ml-2 text-primary-100' : 'ml-2 text-gray-500 dark:text-gray-400'}>
|
||||
{tab.count.toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
<div
|
||||
className={`sticky top-0 z-20 border-b border-gray-200 bg-white/95 py-3 backdrop-blur dark:border-gray-800 dark:bg-gray-950/95 ${className}`}
|
||||
>
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={ariaLabel}
|
||||
className="flex gap-2 overflow-x-auto"
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{tabs.map((tab) => {
|
||||
const isActive = activeTab === tab.id
|
||||
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
id={`${tabListPrefix}-tab-${tab.id}`}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={`${tabListPrefix}-panel-${tab.id}`}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={
|
||||
isActive
|
||||
? 'whitespace-nowrap rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white'
|
||||
: 'whitespace-nowrap rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 transition hover:border-primary-400 hover:text-primary-700 dark:border-gray-700 dark:text-gray-300 dark:hover:text-primary-300'
|
||||
}
|
||||
>
|
||||
{tab.label}
|
||||
{typeof tab.count === 'number' ? (
|
||||
<span className={isActive ? 'ml-2 text-primary-100' : 'ml-2 text-gray-500 dark:text-gray-400'}>
|
||||
{tab.count.toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -26,6 +26,7 @@ import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
|
||||
import SubsystemPosturePanel from '@/components/common/SubsystemPosturePanel'
|
||||
import TokenListSurfaceNote from '@/components/common/TokenListSurfaceNote'
|
||||
import OperationsSurfaceNav from './OperationsSurfaceNav'
|
||||
import LpPositionPanel from '@/components/wallet/LpPositionPanel'
|
||||
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
||||
import {
|
||||
formatCurrency,
|
||||
@@ -252,6 +253,12 @@ export default function LiquidityOperationsPage({
|
||||
href: `/api/v1/report/external-indexer-readiness?chainId=138`,
|
||||
notes: 'One JSON posture for DefiLlama, CoinGecko, CoinMarketCap, and Dexscreener readiness.',
|
||||
},
|
||||
{
|
||||
name: 'Curated pool registry',
|
||||
method: 'GET',
|
||||
href: `${tokenAggregationV1Base}/report/pool-registry?chainId=138`,
|
||||
notes: 'DODO PMM + UniV2 pools with lpTokenType metadata; LP receipt tokens are not bridgeable.',
|
||||
},
|
||||
]
|
||||
|
||||
const copyEndpoint = async (endpoint: EndpointCard) => {
|
||||
@@ -480,6 +487,20 @@ export default function LiquidityOperationsPage({
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<Card title="Curated LP registry (Chain 138)">
|
||||
<LpPositionPanel chainId={138} title="Pool registry & LP scan" compact />
|
||||
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Connect a wallet on the{' '}
|
||||
<Link href="/wallet" className="font-medium text-primary-600 hover:underline dark:text-primary-400">
|
||||
Wallet
|
||||
</Link>{' '}
|
||||
page to scan your LP shares and estimated USD NAV. LP tokens are chain-local — bridge underlying c*/cW*
|
||||
instead.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<Card title="Explorer Access Points">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
|
||||
@@ -991,34 +991,47 @@ export default function Home({
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card title="Quick links" className="mt-8">
|
||||
<Card title="Institutional quick paths" className="mt-8">
|
||||
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
Jump to the explorer surfaces used most often for discovery, liquidity, wallet setup, and bridge monitoring.
|
||||
Canonical discovery, compliance, and liquidity surfaces for institutional users — mesh tokens, official
|
||||
protocols, curated pools, and public JSON APIs.
|
||||
</p>
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Link href="/search" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
|
||||
Search
|
||||
<Link href="/search?q=cWUSDC" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Mesh search</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">c* ↔ cW* across chains</div>
|
||||
</Link>
|
||||
<Link href="/access" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
|
||||
Account access
|
||||
<Link href="/protocols" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Official protocols</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Upstream contracts + guardrails</div>
|
||||
</Link>
|
||||
<Link href="/tokens" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
|
||||
Tokens
|
||||
<Link href="/pools" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Pool registry</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Live DODO PMM + UniV2 TVL</div>
|
||||
</Link>
|
||||
<Link href="/wallet" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
|
||||
Wallet & MetaMask
|
||||
<Link href="/wallet" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Wallet & MetaMask</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">13-chain token import</div>
|
||||
</Link>
|
||||
<Link href="/routes" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
|
||||
Routes
|
||||
<Link href="/liquidity" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Liquidity tools</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">LP policy — bridge underlying, not LP</div>
|
||||
</Link>
|
||||
<Link href="/liquidity" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
|
||||
Liquidity
|
||||
<Link href="/token-aggregation/api/v1/report/official-protocols" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Public JSON APIs</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Protocols, pools, routes</div>
|
||||
</Link>
|
||||
<Link href="/bridge" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
|
||||
Bridge
|
||||
<Link href="/search" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Search</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Address, tx, mesh, pools</div>
|
||||
</Link>
|
||||
<Link href="/analytics" className="rounded-xl border border-gray-200 px-4 py-3 text-sm font-semibold text-primary-600 hover:border-primary-400 dark:border-gray-800">
|
||||
Analytics
|
||||
<Link href="/docs" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Documentation</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">GRU, APIs, operations</div>
|
||||
</Link>
|
||||
<Link href="/bridge" className="rounded-xl border border-gray-200 px-4 py-3 dark:border-gray-800">
|
||||
<div className="text-sm font-semibold text-primary-600">Bridge</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">Relay posture + CCIP lanes</div>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
256
frontend/src/components/pools/PoolDetailPage.tsx
Normal file
256
frontend/src/components/pools/PoolDetailPage.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import OperationsSurfaceNav from '@/components/explorer/OperationsSurfaceNav'
|
||||
import LpPositionPanel from '@/components/wallet/LpPositionPanel'
|
||||
import {
|
||||
fetchPoolRegistry,
|
||||
type CuratedPoolRegistryEntry,
|
||||
} from '@/services/api/liquidityPositions'
|
||||
import { useUiMode } from '@/components/common/UiModeContext'
|
||||
|
||||
function formatUsd(value: number | undefined): string {
|
||||
if (value == null || !Number.isFinite(value)) return 'Unavailable'
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: value >= 1000 ? 0 : 2,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
function formatReserve(raw: string | undefined, symbol: string): string {
|
||||
if (!raw) return 'Unavailable'
|
||||
const decimals = symbol.toUpperCase().includes('BTC') ? 8 : 6
|
||||
try {
|
||||
const value = BigInt(raw)
|
||||
const scale = 10n ** BigInt(decimals)
|
||||
const whole = value / scale
|
||||
const fraction = value % scale
|
||||
const fractionText = fraction
|
||||
.toString()
|
||||
.padStart(decimals, '0')
|
||||
.slice(0, 4)
|
||||
.replace(/0+$/, '')
|
||||
return fractionText
|
||||
? `${whole.toLocaleString()}.${fractionText} ${symbol}`
|
||||
: `${whole.toLocaleString()} ${symbol}`
|
||||
} catch {
|
||||
return 'Unavailable'
|
||||
}
|
||||
}
|
||||
|
||||
function isStableLikeSymbol(symbol: string): boolean {
|
||||
const normalized = symbol.toUpperCase().replace(/^C/, '')
|
||||
return ['USDT', 'USDC', 'EURC', 'EURT', 'GBPC', 'DAI', 'BUSD', 'TUSD', 'FRAX'].includes(normalized)
|
||||
}
|
||||
|
||||
function stableReserveAsymmetryNote(
|
||||
pool: CuratedPoolRegistryEntry,
|
||||
): { severity: 'warning' | 'info'; message: string; deviationBps: number } | null {
|
||||
if (pool.reserve0Usd == null || pool.reserve1Usd == null) return null
|
||||
if (!isStableLikeSymbol(pool.baseSymbol) || !isStableLikeSymbol(pool.quoteSymbol)) return null
|
||||
const legs = [pool.reserve0Usd, pool.reserve1Usd].filter((v) => v > 0)
|
||||
if (legs.length < 2) return null
|
||||
const min = Math.min(...legs)
|
||||
const max = Math.max(...legs)
|
||||
const deviationBps = Math.round(((max - min) / max) * 10000)
|
||||
if (deviationBps < 150) return null
|
||||
return {
|
||||
severity: deviationBps >= 500 ? 'warning' : 'info',
|
||||
deviationBps,
|
||||
message: `Stable-pair reserves are asymmetric (${deviationBps} bps USD leg gap). PMM pools can hold unequal notionals while remaining tradable — do not assume 50/50 peg depth for trade sizing.`,
|
||||
}
|
||||
}
|
||||
|
||||
interface PoolDetailPageProps {
|
||||
poolAddress: string
|
||||
}
|
||||
|
||||
export default function PoolDetailPage({ poolAddress }: PoolDetailPageProps) {
|
||||
const { mode } = useUiMode()
|
||||
const [pools, setPools] = useState<CuratedPoolRegistryEntry[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
void fetchPoolRegistry()
|
||||
.then((report) => {
|
||||
if (!active) return
|
||||
setPools(report?.pools ?? [])
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const pool = useMemo(() => {
|
||||
const lower = poolAddress.toLowerCase()
|
||||
return pools.find(
|
||||
(row) => row.poolAddress.toLowerCase() === lower || row.lpTokenAddress.toLowerCase() === lower,
|
||||
)
|
||||
}, [poolAddress, pools])
|
||||
|
||||
const pairLabel = pool ? `${pool.baseSymbol} / ${pool.quoteSymbol}` : poolAddress
|
||||
const asymmetryNote = pool ? stableReserveAsymmetryNote(pool) : null
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<OperationsSurfaceNav />
|
||||
<PageIntro
|
||||
eyebrow="Curated pool registry"
|
||||
title={loading ? 'Loading pool…' : pool ? pairLabel : 'Pool not in registry'}
|
||||
description={
|
||||
mode === 'guided'
|
||||
? 'Chain-local liquidity pool from the curated DODO PMM + UniV2 registry. LP receipt tokens are not bridgeable — bridge underlying c*/cW* instead.'
|
||||
: 'Curated pool detail · LP is chain-local only.'
|
||||
}
|
||||
actions={[
|
||||
{ href: '/liquidity', label: 'Liquidity tools' },
|
||||
{ href: '/pools', label: 'All pools' },
|
||||
{ href: '/wallet', label: 'Scan LP positions' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Loading pool registry…</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{!loading && !pool ? (
|
||||
<Card title="Not in curated registry">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
This address is not listed in the curated pool registry. It may still be a valid contract on Chain 138 or
|
||||
another network — open the address page for generic explorer data.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<Link href={`/addresses/${poolAddress}`} className="text-primary-600 hover:underline">
|
||||
Open address page →
|
||||
</Link>
|
||||
<Link href="/search" className="text-primary-600 hover:underline">
|
||||
Back to search →
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{pool ? (
|
||||
<div className="space-y-6">
|
||||
{asymmetryNote ? (
|
||||
<Card title="Reserve asymmetry notice">
|
||||
<p
|
||||
className={
|
||||
asymmetryNote.severity === 'warning'
|
||||
? 'text-sm text-amber-800 dark:text-amber-200'
|
||||
: 'text-sm text-gray-700 dark:text-gray-300'
|
||||
}
|
||||
>
|
||||
{asymmetryNote.message}
|
||||
</p>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card title="Pool summary">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<EntityBadge label={pool.venue.replace('_', ' ')} tone="info" />
|
||||
<EntityBadge label={pool.lpTokenType.replace('_', ' ')} tone="neutral" />
|
||||
<EntityBadge label={`chain ${pool.chainId}`} tone="warning" />
|
||||
{pool.integrationStack ? (
|
||||
<EntityBadge label={pool.integrationStack} tone="success" className="normal-case tracking-normal" />
|
||||
) : null}
|
||||
</div>
|
||||
<dl className="mt-4 grid gap-3 text-sm sm:grid-cols-2">
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Pool address</dt>
|
||||
<dd className="mt-1">
|
||||
<Address address={pool.poolAddress} truncate showCopy />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">LP token</dt>
|
||||
<dd className="mt-1">
|
||||
<Address address={pool.lpTokenAddress} truncate showCopy />
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Base</dt>
|
||||
<dd className="mt-1">
|
||||
<Link href={`/tokens/${pool.baseAddress}`} className="text-primary-600 hover:underline">
|
||||
{pool.baseSymbol}
|
||||
</Link>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Quote</dt>
|
||||
<dd className="mt-1">
|
||||
<Link href={`/tokens/${pool.quoteAddress}`} className="text-primary-600 hover:underline">
|
||||
{pool.quoteSymbol}
|
||||
</Link>
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Est. liquidity (USD)</dt>
|
||||
<dd className="mt-1 font-semibold text-gray-900 dark:text-white">
|
||||
{formatUsd(pool.totalLiquidityUsd)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Base reserve</dt>
|
||||
<dd className="mt-1 text-gray-700 dark:text-gray-300">
|
||||
{formatReserve(pool.reserve0, pool.baseSymbol)}
|
||||
{pool.reserve0Usd != null && pool.reserve0Usd > 0 ? (
|
||||
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
({formatUsd(pool.reserve0Usd)})
|
||||
</span>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Quote reserve</dt>
|
||||
<dd className="mt-1 text-gray-700 dark:text-gray-300">
|
||||
{formatReserve(pool.reserve1, pool.quoteSymbol)}
|
||||
{pool.reserve1Usd != null && pool.reserve1Usd > 0 ? (
|
||||
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
({formatUsd(pool.reserve1Usd)})
|
||||
</span>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Liquidity source</dt>
|
||||
<dd className="mt-1 text-gray-700 dark:text-gray-300">
|
||||
{pool.liquiditySource ?? 'curated registry'}
|
||||
{pool.liquidityAsOf ? (
|
||||
<span className="block text-xs text-gray-500 dark:text-gray-400">
|
||||
As of {new Date(pool.liquidityAsOf).toLocaleString()}
|
||||
</span>
|
||||
) : null}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt className="text-gray-500 dark:text-gray-400">Policy</dt>
|
||||
<dd className="mt-1 text-gray-700 dark:text-gray-300">
|
||||
LP shares are chain-local receipts — not cross-chain mesh tokens.
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<LpPositionPanel
|
||||
chainId={pool.chainId}
|
||||
title="Your LP in this pool"
|
||||
compact
|
||||
hintAddresses={[pool.poolAddress, pool.lpTokenAddress]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,30 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
|
||||
import { tokensApi } from '@/services/api/tokens'
|
||||
import { selectWalletFeaturedTokens } from '@/utils/featuredTokens'
|
||||
import MultiChainWalletImport from '@/components/wallet/MultiChainWalletImport'
|
||||
import MobileWalletContextBanner from '@/components/wallet/MobileWalletContextBanner'
|
||||
import WalletFundedTokenListing from '@/components/wallet/WalletFundedTokenListing'
|
||||
import { getActiveWalletConnectProvider } from '@/services/wallet/walletConnectClient'
|
||||
import { toWalletAddEthereumChainParams } from '@/utils/walletAddEthereumChain'
|
||||
import {
|
||||
isMobileWalletContext,
|
||||
MOBILE_WALLET_BUTTON_CLASS,
|
||||
resolveWalletEthereumProvider,
|
||||
type EthereumProvider,
|
||||
} from '@/utils/walletProviderEnv'
|
||||
import { buildWatchAssetRpcRequest, runWatchAssetBatch } from '@/utils/walletWatchAsset'
|
||||
import {
|
||||
CHAIN138_PLACEHOLDER_GAS_SYMBOLS,
|
||||
dedupeWalletWatchTokens,
|
||||
isWalletWatchEligibleAddress,
|
||||
} from '@/utils/walletWatchEligible'
|
||||
import {
|
||||
CHAIN138_NATIVE_ETH_LOGO,
|
||||
formatFundedRowBalanceUsd,
|
||||
fundedRowsToWatchCatalogTokens,
|
||||
loadFundedWalletTokenListing,
|
||||
type FundedWalletTokenRow,
|
||||
} from '@/utils/walletFundedTokenListing'
|
||||
|
||||
export type WalletChain = {
|
||||
chainId: string
|
||||
@@ -117,6 +141,13 @@ export type FetchMetadata = {
|
||||
lastModified?: string | null
|
||||
}
|
||||
|
||||
type PendingWatchFlow = {
|
||||
tokens: TokenListToken[]
|
||||
label: string
|
||||
nextIndex: number
|
||||
totalAdded: number
|
||||
}
|
||||
|
||||
interface AddToMetaMaskProps {
|
||||
initialNetworks?: NetworksCatalog | null
|
||||
initialTokenList?: TokenListCatalog | null
|
||||
@@ -126,8 +157,10 @@ interface AddToMetaMaskProps {
|
||||
initialCapabilitiesMeta?: FetchMetadata | null
|
||||
}
|
||||
|
||||
type EthereumProvider = {
|
||||
request: (args: { method: string; params?: unknown }) => Promise<unknown>
|
||||
function chainParamsForWallet(chain: WalletChain) {
|
||||
return toWalletAddEthereumChainParams(chain, {
|
||||
preferSingleRpc: typeof window !== 'undefined' && isMobileWalletContext(),
|
||||
})
|
||||
}
|
||||
|
||||
const FALLBACK_CHAIN_138: WalletChain = {
|
||||
@@ -135,7 +168,7 @@ const FALLBACK_CHAIN_138: WalletChain = {
|
||||
chainIdDecimal: 138,
|
||||
chainName: 'DeFi Oracle Meta Mainnet',
|
||||
nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 },
|
||||
rpcUrls: ['https://rpc-http-pub.d-bis.org', 'https://rpc.d-bis.org', 'https://rpc2.d-bis.org'],
|
||||
rpcUrls: ['https://rpc-http-pub.d-bis.org'],
|
||||
blockExplorerUrls: ['https://explorer.d-bis.org', 'https://blockscout.defi-oracle.io'],
|
||||
iconUrls: [
|
||||
'https://explorer.d-bis.org/api/v1/report/logo/chain-138',
|
||||
@@ -353,13 +386,36 @@ export function AddToMetaMask({
|
||||
const [metamaskConfigMeta, setMetamaskConfigMeta] = useState<FetchMetadata | null>(null)
|
||||
const [curatedTokens, setCuratedTokens] = useState<TokenListToken[]>([])
|
||||
const [watchAssetProgress, setWatchAssetProgress] = useState<{ current: number; total: number } | null>(null)
|
||||
const [balanceCheckProgress, setBalanceCheckProgress] = useState<{ current: number; total: number } | null>(null)
|
||||
const [fundedTokenRows, setFundedTokenRows] = useState<FundedWalletTokenRow[]>([])
|
||||
const [fundedListingWallet, setFundedListingWallet] = useState<string | null>(null)
|
||||
const [fundedListingLoading, setFundedListingLoading] = useState(false)
|
||||
const [fundedListingError, setFundedListingError] = useState<string | null>(null)
|
||||
const [pendingWatchFlow, setPendingWatchFlow] = useState<PendingWatchFlow | null>(null)
|
||||
const [providerTick, setProviderTick] = useState(0)
|
||||
|
||||
const ethereum = typeof window !== 'undefined'
|
||||
? (window as unknown as { ethereum?: EthereumProvider }).ethereum
|
||||
: undefined
|
||||
const mobileWalletContext = typeof window !== 'undefined' && isMobileWalletContext()
|
||||
|
||||
const resolveEthereum = (): EthereumProvider | undefined => {
|
||||
void providerTick
|
||||
return resolveWalletEthereumProvider(getActiveWalletConnectProvider())
|
||||
}
|
||||
|
||||
const hasWalletProvider = typeof window !== 'undefined' && Boolean(resolveEthereum())
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return undefined
|
||||
const refresh = () => setProviderTick((value) => value + 1)
|
||||
window.addEventListener('ethereum#initialized', refresh)
|
||||
window.addEventListener('focus', refresh)
|
||||
return () => {
|
||||
window.removeEventListener('ethereum#initialized', refresh)
|
||||
window.removeEventListener('focus', refresh)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const apiBase = getApiBase().replace(/\/$/, '')
|
||||
const tokenListUrl = `${apiBase}/api/v1/report/token-list?chainId=138`
|
||||
const tokenListUrl = `${apiBase}/api/v1/report/token-list?chainId=138&wallet=1`
|
||||
const networksUrl = `${apiBase}/api/config/networks`
|
||||
const metamaskConfigUrl = `${apiBase}/api/v1/config/metamask?chainId=138`
|
||||
const capabilitiesUrl = `${apiBase}/api/config/capabilities`
|
||||
@@ -476,7 +532,10 @@ export function AddToMetaMask({
|
||||
}, [])
|
||||
|
||||
const catalogTokens = useMemo(
|
||||
() => (Array.isArray(tokenList?.tokens) ? tokenList.tokens.filter(isTokenListToken) : []),
|
||||
() =>
|
||||
(Array.isArray(tokenList?.tokens) ? tokenList.tokens.filter(isTokenListToken) : []).filter((token) =>
|
||||
isWalletWatchEligibleAddress(token.address),
|
||||
),
|
||||
[tokenList],
|
||||
)
|
||||
|
||||
@@ -502,27 +561,31 @@ export function AddToMetaMask({
|
||||
)
|
||||
|
||||
const watchAssetTokens = useMemo(() => {
|
||||
const endpointTokens = (metamaskConfig?.watchAssets || [])
|
||||
.filter(isWatchAssetEntry)
|
||||
.map(watchAssetToToken)
|
||||
const endpointTokens = dedupeWalletWatchTokens(
|
||||
(metamaskConfig?.watchAssets || [])
|
||||
.filter(isWatchAssetEntry)
|
||||
.map(watchAssetToToken)
|
||||
.filter((token) => isWalletWatchEligibleAddress(token.address)),
|
||||
)
|
||||
|
||||
if (endpointTokens.length > 0) return endpointTokens
|
||||
return catalogTokens.filter((token) => token.chainId === 138)
|
||||
return dedupeWalletWatchTokens(catalogTokens.filter((token) => token.chainId === 138))
|
||||
}, [catalogTokens, metamaskConfig])
|
||||
|
||||
const addChain = async (chain: WalletChain) => {
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
|
||||
const ethereum = resolveEthereum()
|
||||
if (!ethereum) {
|
||||
setError('MetaMask or another Web3 wallet is not installed.')
|
||||
setError('No wallet provider found. Open this page in MetaMask mobile, install a browser wallet, or connect WalletConnect above.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await ethereum.request({
|
||||
method: 'wallet_addEthereumChain',
|
||||
params: [chain],
|
||||
params: [chainParamsForWallet(chain)],
|
||||
})
|
||||
setStatus(`Added ${chain.chainName}. You can switch to it in your wallet.`)
|
||||
} catch (e) {
|
||||
@@ -536,8 +599,9 @@ export function AddToMetaMask({
|
||||
}
|
||||
|
||||
const switchOrAddChain = async (chain: WalletChain) => {
|
||||
const ethereum = resolveEthereum()
|
||||
if (!ethereum) {
|
||||
setError('MetaMask or another Web3 wallet is not installed.')
|
||||
setError('No wallet provider found. Open this page in MetaMask mobile, install a browser wallet, or connect WalletConnect above.')
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -558,7 +622,7 @@ export function AddToMetaMask({
|
||||
try {
|
||||
await ethereum.request({
|
||||
method: 'wallet_addEthereumChain',
|
||||
params: [chain],
|
||||
params: [chainParamsForWallet(chain)],
|
||||
})
|
||||
return true
|
||||
} catch (e) {
|
||||
@@ -572,8 +636,9 @@ export function AddToMetaMask({
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
|
||||
const ethereum = resolveEthereum()
|
||||
if (!ethereum) {
|
||||
setError('MetaMask or another Web3 wallet is not installed.')
|
||||
setError('No wallet provider found. Snaps require MetaMask in a supported browser.')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -602,6 +667,13 @@ export function AddToMetaMask({
|
||||
}
|
||||
}
|
||||
|
||||
const networkForToken = (token: TokenListToken): WalletChain | null => {
|
||||
if (token.chainId === 138) return chains.chain138
|
||||
if (token.chainId === 1) return chains.ethereum
|
||||
if (token.chainId === 651940) return chains.allMainnet
|
||||
return null
|
||||
}
|
||||
|
||||
const watchToken = async (token: TokenListToken) => {
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
@@ -611,32 +683,118 @@ export function AddToMetaMask({
|
||||
return
|
||||
}
|
||||
|
||||
if (!ethereum) {
|
||||
setError('MetaMask or another Web3 wallet is not installed.')
|
||||
if (!isWalletWatchEligibleAddress(token.address)) {
|
||||
setError(
|
||||
`${token.symbol} uses a roadmap placeholder address on Chain 138 (not a live ERC-20). Remove it from MetaMask if it was added earlier, then use Add all Chain 138 tokens for live contracts such as cUSDT, cUSDC, and cBTC.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const added = await ethereum.request({
|
||||
method: 'wallet_watchAsset',
|
||||
params: {
|
||||
type: 'ERC20',
|
||||
options: {
|
||||
address: token.address,
|
||||
symbol: token.symbol,
|
||||
decimals: token.decimals,
|
||||
image: token.logoURI,
|
||||
},
|
||||
},
|
||||
})
|
||||
const ethereum = resolveEthereum()
|
||||
if (!ethereum) {
|
||||
setError('No wallet provider found. Open this page in MetaMask mobile, install a browser wallet, or connect WalletConnect above.')
|
||||
return
|
||||
}
|
||||
|
||||
setStatus(added ? `Added ${token.symbol} to your wallet.` : `${token.symbol} request was dismissed.`)
|
||||
const network = networkForToken(token)
|
||||
if (network) {
|
||||
const switched = await switchOrAddChain(network)
|
||||
if (!switched) return
|
||||
}
|
||||
|
||||
try {
|
||||
const added = await ethereum.request(buildWatchAssetRpcRequest(token, mobileWalletContext))
|
||||
|
||||
setStatus(
|
||||
added
|
||||
? `Added ${token.symbol} on ${network?.chainName || `chain ${token.chainId}`}. Switch to that network in MetaMask to see balances.`
|
||||
: `${token.symbol} request was dismissed.`,
|
||||
)
|
||||
} catch (e) {
|
||||
const err = e as { message?: string }
|
||||
setError(err.message || `Failed to add ${token.symbol}.`)
|
||||
}
|
||||
}
|
||||
|
||||
const watchAssetCatalogTokens = useMemo(
|
||||
() =>
|
||||
watchAssetTokens.map((token) => ({
|
||||
chainId: token.chainId,
|
||||
address: token.address,
|
||||
symbol: token.symbol,
|
||||
name: token.name,
|
||||
decimals: token.decimals,
|
||||
logoURI: token.logoURI,
|
||||
})),
|
||||
[watchAssetTokens],
|
||||
)
|
||||
|
||||
const requestConnectedWalletAddress = async (): Promise<string | null> => {
|
||||
const ethereum = resolveEthereum()
|
||||
if (!ethereum) {
|
||||
setError('No wallet provider found. Open this page in MetaMask mobile, install a browser wallet, or connect WalletConnect above.')
|
||||
return null
|
||||
}
|
||||
|
||||
const switched = await switchOrAddChain(chains.chain138)
|
||||
if (!switched) return null
|
||||
|
||||
try {
|
||||
const accounts = (await ethereum.request({ method: 'eth_requestAccounts' })) as string[]
|
||||
return accounts[0] || null
|
||||
} catch (e) {
|
||||
const err = e as { message?: string }
|
||||
setError(err.message || 'Could not connect a wallet account.')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const refreshFundedTokenListing = async () => {
|
||||
setFundedListingError(null)
|
||||
setFundedListingLoading(true)
|
||||
setBalanceCheckProgress(null)
|
||||
|
||||
try {
|
||||
const walletAddress = await requestConnectedWalletAddress()
|
||||
if (!walletAddress) {
|
||||
setFundedTokenRows([])
|
||||
setFundedListingWallet(null)
|
||||
return
|
||||
}
|
||||
|
||||
const ethereum = resolveEthereum()
|
||||
if (!ethereum) return
|
||||
|
||||
const rows = await loadFundedWalletTokenListing(
|
||||
ethereum,
|
||||
walletAddress,
|
||||
watchAssetCatalogTokens,
|
||||
{
|
||||
nativeLogoUri: CHAIN138_NATIVE_ETH_LOGO,
|
||||
onProgress: (current, total) => setBalanceCheckProgress({ current, total }),
|
||||
},
|
||||
)
|
||||
|
||||
setFundedTokenRows(rows)
|
||||
setFundedListingWallet(walletAddress)
|
||||
setBalanceCheckProgress(null)
|
||||
|
||||
const erc20Count = rows.filter((row) => row.kind === 'erc20').length
|
||||
setStatus(
|
||||
rows.length > 0
|
||||
? `Loaded ${rows.length} funded holding(s) for ${walletAddress.slice(0, 6)}…${walletAddress.slice(-4)} (${erc20Count} ERC-20 + native ETH when present).`
|
||||
: `No funded catalog tokens for ${walletAddress.slice(0, 6)}…${walletAddress.slice(-4)}.`,
|
||||
)
|
||||
} catch (e) {
|
||||
const err = e as { message?: string }
|
||||
setFundedListingError(err.message || 'Failed to load funded token listing.')
|
||||
setFundedTokenRows([])
|
||||
setFundedListingWallet(null)
|
||||
} finally {
|
||||
setFundedListingLoading(false)
|
||||
setBalanceCheckProgress(null)
|
||||
}
|
||||
}
|
||||
const refreshMainnetCwusdc = async () => {
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
@@ -647,51 +805,140 @@ export function AddToMetaMask({
|
||||
await watchToken(MAINNET_CWUSDC_TOKEN)
|
||||
}
|
||||
|
||||
const watchTokensSequentially = async (tokens: TokenListToken[], label: string) => {
|
||||
const watchTokensSequentially = async (
|
||||
tokens: TokenListToken[],
|
||||
label: string,
|
||||
startIndex = 0,
|
||||
priorAdded = 0,
|
||||
) => {
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
if (startIndex === 0) {
|
||||
setStatus(null)
|
||||
setPendingWatchFlow(null)
|
||||
}
|
||||
setWatchAssetProgress(null)
|
||||
setBalanceCheckProgress(null)
|
||||
|
||||
const ethereum = resolveEthereum()
|
||||
if (!ethereum) {
|
||||
setError('MetaMask or another Web3 wallet is not installed.')
|
||||
setError('No wallet provider found. Open this page in MetaMask mobile, install a browser wallet, or connect WalletConnect above.')
|
||||
return
|
||||
}
|
||||
|
||||
const validTokens = tokens.filter(isTokenListToken)
|
||||
const validTokens = dedupeWalletWatchTokens(
|
||||
tokens.filter(isTokenListToken).filter((token) => isWalletWatchEligibleAddress(token.address)),
|
||||
)
|
||||
if (validTokens.length === 0) {
|
||||
setError('No live Chain 138 token metadata is available for wallet_watchAsset right now.')
|
||||
return
|
||||
}
|
||||
|
||||
const switched = await switchOrAddChain(chains.chain138)
|
||||
if (!switched) return
|
||||
|
||||
const batch = await runWatchAssetBatch(ethereum, validTokens, startIndex, {
|
||||
mobile: mobileWalletContext,
|
||||
onProgress: (current, total) => setWatchAssetProgress({ current, total }),
|
||||
})
|
||||
|
||||
const totalAdded = priorAdded + batch.addedCount
|
||||
setWatchAssetProgress(null)
|
||||
|
||||
if (batch.stoppedEarly) {
|
||||
setError(batch.errorMessage || 'Token import stopped.')
|
||||
setPendingWatchFlow(null)
|
||||
setStatus(`${totalAdded} of ${validTokens.length} ${label} token requests were accepted before the flow stopped.`)
|
||||
return
|
||||
}
|
||||
|
||||
if (batch.nextIndex < validTokens.length) {
|
||||
setPendingWatchFlow({
|
||||
tokens: validTokens,
|
||||
label,
|
||||
nextIndex: batch.nextIndex,
|
||||
totalAdded,
|
||||
})
|
||||
setStatus(
|
||||
`${totalAdded} of ${validTokens.length} ${label} tokens added. Tap Continue for the next ${Math.min(validTokens.length - batch.nextIndex, 2)} prompt(s). On mobile, approve each wallet popup before it closes.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setPendingWatchFlow(null)
|
||||
setStatus(
|
||||
`${totalAdded} of ${validTokens.length} ${label} token requests were accepted. Native ETH appears automatically on DeFi Oracle Meta Mainnet (not via Add Token). Stay on Chain 138 with RPC https://rpc-http-pub.d-bis.org to see balances.`,
|
||||
)
|
||||
}
|
||||
|
||||
const continuePendingWatchFlow = async () => {
|
||||
if (!pendingWatchFlow) return
|
||||
const { tokens, label, nextIndex, totalAdded } = pendingWatchFlow
|
||||
await watchTokensSequentially(tokens, label, nextIndex, totalAdded)
|
||||
}
|
||||
|
||||
const watchTokensWithBalanceOnly = async () => {
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
setWatchAssetProgress(null)
|
||||
setBalanceCheckProgress(null)
|
||||
setFundedListingError(null)
|
||||
|
||||
const ethereum = resolveEthereum()
|
||||
if (!ethereum) {
|
||||
setError('No wallet provider found. Open this page in MetaMask mobile, install a browser wallet, or connect WalletConnect above.')
|
||||
return
|
||||
}
|
||||
|
||||
const candidates = watchAssetTokens.filter(isTokenListToken)
|
||||
if (candidates.length === 0) {
|
||||
setError('No complete token metadata is available for wallet_watchAsset right now.')
|
||||
return
|
||||
}
|
||||
|
||||
let addedCount = 0
|
||||
for (let index = 0; index < validTokens.length; index += 1) {
|
||||
const token = validTokens[index]
|
||||
setWatchAssetProgress({ current: index + 1, total: validTokens.length })
|
||||
try {
|
||||
const added = await ethereum.request({
|
||||
method: 'wallet_watchAsset',
|
||||
params: {
|
||||
type: 'ERC20',
|
||||
options: {
|
||||
address: token.address,
|
||||
symbol: token.symbol,
|
||||
decimals: token.decimals,
|
||||
image: token.logoURI,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (added) addedCount += 1
|
||||
} catch (e) {
|
||||
const err = e as { message?: string }
|
||||
setError(err.message || `Stopped while adding ${token.symbol}.`)
|
||||
setStatus(`${addedCount} of ${validTokens.length} ${label} token requests were accepted before the flow stopped.`)
|
||||
setWatchAssetProgress(null)
|
||||
setFundedListingLoading(true)
|
||||
try {
|
||||
const walletAddress = await requestConnectedWalletAddress()
|
||||
if (!walletAddress) return
|
||||
|
||||
setStatus(`Checking ERC-20 balances and market prices for ${candidates.length} catalog tokens on Chain 138…`)
|
||||
|
||||
const rows = await loadFundedWalletTokenListing(
|
||||
ethereum,
|
||||
walletAddress,
|
||||
watchAssetCatalogTokens,
|
||||
{
|
||||
nativeLogoUri: CHAIN138_NATIVE_ETH_LOGO,
|
||||
onProgress: (current, total) => setBalanceCheckProgress({ current, total }),
|
||||
},
|
||||
)
|
||||
|
||||
setFundedTokenRows(rows)
|
||||
setFundedListingWallet(walletAddress)
|
||||
setBalanceCheckProgress(null)
|
||||
|
||||
const fundedTokens = fundedRowsToWatchCatalogTokens(rows)
|
||||
if (fundedTokens.length === 0) {
|
||||
const nativeRow = rows.find((row) => row.kind === 'native')
|
||||
setStatus(
|
||||
nativeRow
|
||||
? `Native ETH: ${nativeRow.balanceLabel} (${formatFundedRowBalanceUsd(nativeRow)}). No ERC-20 balances among catalog tokens — nothing to add via EIP-747.`
|
||||
: `No ERC-20 balances found among ${candidates.length} catalog tokens for ${walletAddress.slice(0, 6)}…${walletAddress.slice(-4)}.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
setWatchAssetProgress(null)
|
||||
setStatus(`${addedCount} of ${validTokens.length} ${label} token requests were accepted by the wallet.`)
|
||||
const symbols = fundedTokens.map((token) => token.symbol).join(', ')
|
||||
setStatus(
|
||||
`Found ${fundedTokens.length} ERC-20 token(s) with balance (${symbols}). Native ETH is listed above when present. Starting wallet prompts…`,
|
||||
)
|
||||
await watchTokensSequentially(fundedTokens as TokenListToken[], 'funded Chain 138')
|
||||
} catch (e) {
|
||||
const err = e as { message?: string }
|
||||
setError(err.message || 'Failed to read token balances from Chain 138.')
|
||||
setBalanceCheckProgress(null)
|
||||
} finally {
|
||||
setFundedListingLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const copyText = async (value: string, label: string) => {
|
||||
@@ -727,25 +974,38 @@ export function AddToMetaMask({
|
||||
metadata directly to the wallet.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<MobileWalletContextBanner hasProvider={hasWalletProvider} />
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void switchOrAddChain(chains.chain138).then((ok) => {
|
||||
if (ok) setStatus('Switched to DeFi Oracle Meta Mainnet. Native ETH and imported tokens show on this network.')
|
||||
})
|
||||
}
|
||||
className={`rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Switch to Chain 138
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addChain(chains.chain138)}
|
||||
className="rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700"
|
||||
className={`rounded bg-primary-600 px-4 py-2 text-sm font-medium text-white hover:bg-primary-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add Chain 138
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addChain(chains.ethereum)}
|
||||
className="rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700"
|
||||
className={`rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add Ethereum Mainnet
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => addChain(chains.allMainnet)}
|
||||
className="rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700"
|
||||
className={`rounded bg-gray-600 px-4 py-2 text-sm font-medium text-white hover:bg-gray-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add ALL Mainnet
|
||||
</button>
|
||||
@@ -893,29 +1153,84 @@ export function AddToMetaMask({
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
These tokens come from the explorer MetaMask payload and use wallet_watchAsset so the wallet gets the same
|
||||
symbol, decimals, image, and optional token metadata that the explorer publishes. MetaMask requires a user
|
||||
approval for each token, so the bulk actions below run as a guided sequence of wallet prompts.
|
||||
approval for each token, so the bulk actions below run as a guided sequence of wallet prompts. Bulk add
|
||||
switches to DeFi Oracle Meta Mainnet first — tokens imported while on another network will not show
|
||||
balances on Chain 138. Native ETH is not added via this flow; it appears when you are on Chain 138 with a
|
||||
working RPC. Use <span className="font-medium">Add tokens with balance only</span> to skip zero-balance and
|
||||
undeployed catalog entries.
|
||||
{mobileWalletContext
|
||||
? ' On mobile, imports run two wallet prompts per tap — use Continue until finished.'
|
||||
: null}
|
||||
</p>
|
||||
<p className="mt-2 rounded-lg border border-amber-200 bg-amber-50/80 p-3 text-sm leading-6 text-amber-950 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-100">
|
||||
MetaMask keeps <span className="font-medium">one row per token symbol</span> on a network. If a balance
|
||||
disappears after import, remove duplicate custom tokens (especially second <code className="text-xs">cUSDC</code>{' '}
|
||||
/ <code className="text-xs">cUSDT</code> rows or old gas placeholders like{' '}
|
||||
{CHAIN138_PLACEHOLDER_GAS_SYMBOLS.slice(0, 3).join(', ')}), stay on{' '}
|
||||
<span className="font-medium">DeFi Oracle Meta Mainnet</span>, then tap{' '}
|
||||
<span className="font-medium">Add tokens with balance only</span> or{' '}
|
||||
<span className="font-medium">Add featured tokens</span>.
|
||||
</p>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{pendingWatchFlow ? (
|
||||
<button
|
||||
type="button"
|
||||
disabled={watchAssetProgress !== null || balanceCheckProgress !== null || fundedListingLoading}
|
||||
onClick={() => void continuePendingWatchFlow()}
|
||||
className={`rounded bg-amber-600 px-3 py-2 text-sm font-medium text-white hover:bg-amber-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Continue ({pendingWatchFlow.totalAdded} added, {pendingWatchFlow.tokens.length - pendingWatchFlow.nextIndex} left)
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
disabled={watchAssetProgress !== null || balanceCheckProgress !== null || fundedListingLoading}
|
||||
onClick={() => void watchTokensWithBalanceOnly()}
|
||||
className={`rounded bg-emerald-600 px-3 py-2 text-sm font-medium text-white hover:bg-emerald-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add tokens with balance only
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={watchAssetProgress !== null || balanceCheckProgress !== null || fundedListingLoading || pendingWatchFlow !== null}
|
||||
onClick={() => void watchTokensSequentially(featuredTokens, 'featured Chain 138')}
|
||||
className="rounded bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700"
|
||||
className={`rounded bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add featured tokens
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={watchAssetProgress !== null || balanceCheckProgress !== null || fundedListingLoading || pendingWatchFlow !== null}
|
||||
onClick={() => void watchTokensSequentially(watchAssetTokens, 'Chain 138')}
|
||||
className="rounded bg-gray-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-gray-700"
|
||||
className={`rounded bg-gray-600 px-3 py-2 text-sm font-medium text-white hover:bg-gray-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add all Chain 138 tokens
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={watchAssetProgress !== null || balanceCheckProgress !== null || fundedListingLoading}
|
||||
onClick={() => void refreshFundedTokenListing()}
|
||||
className={`rounded border border-emerald-600 px-3 py-2 text-sm font-medium text-emerald-700 hover:bg-emerald-50 disabled:opacity-50 dark:text-emerald-300 dark:hover:bg-emerald-950/30 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Refresh funded holdings
|
||||
</button>
|
||||
{balanceCheckProgress ? (
|
||||
<span className="self-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Balance check {balanceCheckProgress.current} of {balanceCheckProgress.total}
|
||||
</span>
|
||||
) : null}
|
||||
{watchAssetProgress ? (
|
||||
<span className="self-center text-sm text-gray-600 dark:text-gray-400">
|
||||
Prompt {watchAssetProgress.current} of {watchAssetProgress.total}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<WalletFundedTokenListing
|
||||
rows={fundedTokenRows}
|
||||
walletAddress={fundedListingWallet}
|
||||
loading={fundedListingLoading}
|
||||
error={fundedListingError}
|
||||
/>
|
||||
<div className="mt-4 space-y-3">
|
||||
{featuredTokens.length === 0 ? (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">Featured token metadata is not available right now.</p>
|
||||
@@ -940,7 +1255,7 @@ export function AddToMetaMask({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => watchToken(token)}
|
||||
className="rounded bg-primary-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-primary-700"
|
||||
className={`rounded bg-primary-600 px-3 py-2 text-sm font-medium text-white hover:bg-primary-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add {token.symbol}
|
||||
</button>
|
||||
@@ -982,6 +1297,8 @@ export function AddToMetaMask({
|
||||
|
||||
{status ? <p className="text-sm text-green-600 dark:text-green-400">{status}</p> : null}
|
||||
{error ? <p className="text-sm text-red-600 dark:text-red-400">{error}</p> : null}
|
||||
|
||||
<MultiChainWalletImport />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
169
frontend/src/components/wallet/LpPositionPanel.tsx
Normal file
169
frontend/src/components/wallet/LpPositionPanel.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import {
|
||||
fetchLpPositions,
|
||||
fetchPoolRegistry,
|
||||
type LpPositionRow,
|
||||
type PoolRegistryResponse,
|
||||
} from '@/services/api/liquidityPositions'
|
||||
import { chainLabel } from '@/utils/walletChainCatalog'
|
||||
|
||||
function formatUsd(value: number | null | undefined): string {
|
||||
if (value == null || Number.isNaN(value)) return '—'
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: value >= 1000 ? 0 : 2,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
function formatShare(value: number): string {
|
||||
if (!Number.isFinite(value) || value <= 0) return '0%'
|
||||
return `${(value * 100).toFixed(value < 0.01 ? 4 : 2)}%`
|
||||
}
|
||||
|
||||
interface LpPositionPanelProps {
|
||||
chainId?: number
|
||||
address?: string | null
|
||||
hintAddresses?: string[]
|
||||
title?: string
|
||||
compact?: boolean
|
||||
}
|
||||
|
||||
export default function LpPositionPanel({
|
||||
chainId = 138,
|
||||
address,
|
||||
hintAddresses = [],
|
||||
title = 'LP positions',
|
||||
compact = false,
|
||||
}: LpPositionPanelProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [positions, setPositions] = useState<LpPositionRow[]>([])
|
||||
const [totalUsd, setTotalUsd] = useState<number | null>(null)
|
||||
const [registry, setRegistry] = useState<PoolRegistryResponse | null>(null)
|
||||
const [notes, setNotes] = useState<string[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
void fetchPoolRegistry(chainId).then(setRegistry).catch(() => setRegistry(null))
|
||||
}, [chainId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!address) {
|
||||
setPositions([])
|
||||
setTotalUsd(null)
|
||||
setNotes([])
|
||||
return
|
||||
}
|
||||
|
||||
let active = true
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
void fetchLpPositions({ chainId, address, hintAddresses })
|
||||
.then((report) => {
|
||||
if (!active) return
|
||||
if (!report) {
|
||||
setError('LP position scan is unavailable right now.')
|
||||
setPositions([])
|
||||
setTotalUsd(null)
|
||||
return
|
||||
}
|
||||
setPositions(report.positions)
|
||||
setTotalUsd(report.totalEstimatedUsd)
|
||||
setNotes(report.notes)
|
||||
})
|
||||
.catch(() => {
|
||||
if (!active) return
|
||||
setError('LP position scan failed.')
|
||||
setPositions([])
|
||||
setTotalUsd(null)
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [address, chainId, hintAddresses.join(',')])
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 p-4 dark:border-gray-800 dark:bg-gray-900/40">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{title}</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
Curated DODO PMM + UniV2 pools on {chainLabel(chainId)}
|
||||
{registry ? ` · ${registry.count} pools in registry` : ''}
|
||||
{address ? '' : ' · connect a wallet to scan LP shares'}
|
||||
</div>
|
||||
</div>
|
||||
{totalUsd != null ? (
|
||||
<div className="text-right">
|
||||
<div className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Estimated LP NAV</div>
|
||||
<div className="text-lg font-semibold text-gray-900 dark:text-white">{formatUsd(totalUsd)}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="mt-4 text-sm text-gray-600 dark:text-gray-400">Scanning on-chain LP balances…</div>
|
||||
) : null}
|
||||
{error ? <div className="mt-4 text-sm text-red-600 dark:text-red-400">{error}</div> : null}
|
||||
|
||||
{!loading && !error && address && positions.length === 0 ? (
|
||||
<div className="mt-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
No active LP shares detected in the curated pool registry for this address.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{positions.length > 0 ? (
|
||||
<div className={`mt-4 space-y-3 ${compact ? '' : ''}`}>
|
||||
{positions.map((position) => (
|
||||
<div
|
||||
key={position.poolAddress}
|
||||
className="rounded-xl border border-gray-200 bg-white/80 px-4 py-3 dark:border-gray-700 dark:bg-black/10"
|
||||
>
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">
|
||||
{position.pairLabel}{' '}
|
||||
<span className="text-xs font-normal text-gray-500 dark:text-gray-400">
|
||||
({position.venue.replace('_', ' ')})
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Pool {position.poolAddress.slice(0, 10)}…{position.poolAddress.slice(-8)} · share{' '}
|
||||
{formatShare(position.shareOfPool)} · balance {position.shareBalanceUnits}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-col items-end gap-1">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{formatUsd(position.estimatedUsd)}
|
||||
</div>
|
||||
<Link href={`/pools/${position.poolAddress}`} className="text-xs text-primary-600 hover:underline">
|
||||
Pool detail →
|
||||
</Link>
|
||||
<Link href={`/liquidity`} className="text-xs text-primary-600 hover:underline">
|
||||
Liquidity tools →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{notes.length > 0 ? (
|
||||
<div className="mt-4 space-y-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{notes.map((note) => (
|
||||
<p key={note}>{note}</p>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
49
frontend/src/components/wallet/MobileWalletContextBanner.tsx
Normal file
49
frontend/src/components/wallet/MobileWalletContextBanner.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
buildCoinbaseWalletDappUrl,
|
||||
buildMetaMaskMobileDappUrl,
|
||||
isMobileBrowser,
|
||||
isWalletInAppBrowser,
|
||||
MOBILE_WALLET_BUTTON_CLASS,
|
||||
} from '@/utils/walletProviderEnv'
|
||||
|
||||
type MobileWalletContextBannerProps = {
|
||||
hasProvider: boolean
|
||||
}
|
||||
|
||||
export default function MobileWalletContextBanner({ hasProvider }: MobileWalletContextBannerProps) {
|
||||
if (typeof window === 'undefined') return null
|
||||
if (!isMobileBrowser() || isWalletInAppBrowser()) return null
|
||||
|
||||
const pageUrl = window.location.href
|
||||
const metamaskUrl = buildMetaMaskMobileDappUrl(pageUrl)
|
||||
const coinbaseUrl = buildCoinbaseWalletDappUrl(pageUrl)
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-sky-200 bg-sky-50/80 p-4 dark:border-sky-900/50 dark:bg-sky-950/30">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Mobile wallet browser</div>
|
||||
<p className="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{hasProvider
|
||||
? 'You are in a mobile browser with a wallet provider. Add-chain and add-token buttons work best one or two tokens at a time — use Continue when prompted. If a prompt does not appear, unlock your wallet and stay on this tab.'
|
||||
: 'Mobile Safari/Chrome do not expose a wallet extension. Open this page inside MetaMask or Coinbase Wallet, or connect with WalletConnect above, then use the add-chain and add-token buttons.'}
|
||||
</p>
|
||||
{!hasProvider ? (
|
||||
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:flex-wrap">
|
||||
<a
|
||||
href={metamaskUrl}
|
||||
className={`inline-flex items-center justify-center rounded-lg bg-orange-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-orange-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Open in MetaMask
|
||||
</a>
|
||||
<a
|
||||
href={coinbaseUrl}
|
||||
className={`inline-flex items-center justify-center rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-blue-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Open in Coinbase Wallet
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
336
frontend/src/components/wallet/MultiChainWalletImport.tsx
Normal file
336
frontend/src/components/wallet/MultiChainWalletImport.tsx
Normal file
@@ -0,0 +1,336 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { resolveExplorerApiBase } from '@/libs/frontend-api-client/api-base'
|
||||
import type { TokenListToken, WalletChain } from '@/components/wallet/AddToMetaMask'
|
||||
import { getActiveWalletConnectProvider } from '@/services/wallet/walletConnectClient'
|
||||
import {
|
||||
WALLET_FEATURED_SYMBOLS_BY_CHAIN,
|
||||
WALLET_IMPORT_CHAIN_IDS,
|
||||
chainLabel,
|
||||
} from '@/utils/walletChainCatalog'
|
||||
import { toWalletAddEthereumChainParams } from '@/utils/walletAddEthereumChain'
|
||||
import {
|
||||
isMobileWalletContext,
|
||||
MOBILE_WALLET_BUTTON_CLASS,
|
||||
resolveWalletEthereumProvider,
|
||||
} from '@/utils/walletProviderEnv'
|
||||
import { runWatchAssetBatch } from '@/utils/walletWatchAsset'
|
||||
import { dedupeWalletWatchTokens } from '@/utils/walletWatchEligible'
|
||||
|
||||
type WatchAssetEntry = {
|
||||
type: 'ERC20'
|
||||
options: {
|
||||
address: string
|
||||
symbol: string
|
||||
decimals: number
|
||||
image?: string
|
||||
}
|
||||
}
|
||||
|
||||
type MetaMaskChainPayload = {
|
||||
chainId?: number
|
||||
addEthereumChain?: WalletChain
|
||||
watchAssets?: WatchAssetEntry[]
|
||||
}
|
||||
|
||||
type ChainImportState = {
|
||||
chainId: number
|
||||
network: WalletChain | null
|
||||
tokens: TokenListToken[]
|
||||
loading: boolean
|
||||
error: string | null
|
||||
}
|
||||
|
||||
type PendingMultiChainFlow = {
|
||||
chainId: number
|
||||
tokens: TokenListToken[]
|
||||
label: string
|
||||
nextIndex: number
|
||||
totalAdded: number
|
||||
}
|
||||
|
||||
function isTokenListToken(value: unknown): value is TokenListToken {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const candidate = value as Partial<TokenListToken>
|
||||
return (
|
||||
typeof candidate.chainId === 'number' &&
|
||||
typeof candidate.address === 'string' &&
|
||||
typeof candidate.symbol === 'string' &&
|
||||
typeof candidate.decimals === 'number'
|
||||
)
|
||||
}
|
||||
|
||||
function watchAssetToToken(chainId: number, entry: WatchAssetEntry): TokenListToken {
|
||||
return {
|
||||
chainId,
|
||||
address: entry.options.address,
|
||||
symbol: entry.options.symbol,
|
||||
name: entry.options.symbol,
|
||||
decimals: entry.options.decimals,
|
||||
logoURI: entry.options.image,
|
||||
}
|
||||
}
|
||||
|
||||
function getApiBase() {
|
||||
return resolveExplorerApiBase({
|
||||
browserOrigin: '',
|
||||
serverFallback: 'https://explorer.d-bis.org',
|
||||
}).replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function chainParamsForWallet(chain: WalletChain) {
|
||||
return toWalletAddEthereumChainParams(chain, {
|
||||
preferSingleRpc: typeof window !== 'undefined' && isMobileWalletContext(),
|
||||
})
|
||||
}
|
||||
|
||||
export default function MultiChainWalletImport() {
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [progress, setProgress] = useState<{ current: number; total: number; chainId: number } | null>(null)
|
||||
const [chains, setChains] = useState<ChainImportState[]>([])
|
||||
const [pendingFlow, setPendingFlow] = useState<PendingMultiChainFlow | null>(null)
|
||||
const mobileWalletContext = typeof window !== 'undefined' && isMobileWalletContext()
|
||||
|
||||
const resolveEthereum = () => resolveWalletEthereumProvider(getActiveWalletConnectProvider())
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
const apiBase = getApiBase()
|
||||
|
||||
async function loadChain(chainId: number): Promise<ChainImportState> {
|
||||
try {
|
||||
const [networkRes, metamaskRes] = await Promise.all([
|
||||
fetch(`${apiBase}/api/config/networks`, { cache: 'no-store' }),
|
||||
fetch(`${apiBase}/api/v1/config/metamask?chainId=${chainId}`, { cache: 'no-store' }),
|
||||
])
|
||||
const networksJson = networkRes.ok ? await networkRes.json() : null
|
||||
const metamaskJson = metamaskRes.ok ? await metamaskRes.json() : null
|
||||
const networkList = Array.isArray(networksJson?.chains) ? networksJson.chains : []
|
||||
const network =
|
||||
(metamaskJson as MetaMaskChainPayload | null)?.addEthereumChain ||
|
||||
networkList.find((row: WalletChain) => row.chainIdDecimal === chainId) ||
|
||||
null
|
||||
const watchAssets = Array.isArray((metamaskJson as MetaMaskChainPayload | null)?.watchAssets)
|
||||
? ((metamaskJson as MetaMaskChainPayload).watchAssets ?? [])
|
||||
: []
|
||||
let tokens = watchAssets.map((entry) => watchAssetToToken(chainId, entry))
|
||||
if (tokens.length === 0) {
|
||||
const listRes = await fetch(`${apiBase}/api/v1/report/token-list?chainId=${chainId}`, { cache: 'no-store' })
|
||||
const listJson = listRes.ok ? await listRes.json() : null
|
||||
tokens = (Array.isArray(listJson?.tokens) ? listJson.tokens : []).filter(isTokenListToken)
|
||||
}
|
||||
return { chainId, network, tokens, loading: false, error: null }
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : 'Failed to load chain metadata'
|
||||
return { chainId, network: null, tokens: [], loading: false, error: message }
|
||||
}
|
||||
}
|
||||
|
||||
void (async () => {
|
||||
const initial = WALLET_IMPORT_CHAIN_IDS.map((chainId) => ({
|
||||
chainId,
|
||||
network: null,
|
||||
tokens: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
}))
|
||||
if (active) setChains(initial)
|
||||
|
||||
const loaded = await Promise.all(WALLET_IMPORT_CHAIN_IDS.map((chainId) => loadChain(chainId)))
|
||||
if (active) setChains(loaded)
|
||||
})()
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const featuredByChain = useMemo(() => {
|
||||
return new Map(
|
||||
chains.map((row) => {
|
||||
const featuredSymbols = new Set(WALLET_FEATURED_SYMBOLS_BY_CHAIN[row.chainId] ?? [])
|
||||
const featured = row.tokens.filter((token) => featuredSymbols.has(token.symbol))
|
||||
return [row.chainId, featured.length > 0 ? featured : row.tokens.slice(0, 8)]
|
||||
}),
|
||||
)
|
||||
}, [chains])
|
||||
|
||||
const switchOrAddChain = async (network: WalletChain) => {
|
||||
const ethereum = resolveEthereum()
|
||||
if (!ethereum) {
|
||||
setError('No wallet provider found. Use MetaMask mobile in-app browser or WalletConnect.')
|
||||
return false
|
||||
}
|
||||
try {
|
||||
await ethereum.request({ method: 'wallet_switchEthereumChain', params: [{ chainId: network.chainId }] })
|
||||
return true
|
||||
} catch (e) {
|
||||
const err = e as { code?: number; message?: string }
|
||||
if (err.code !== 4902) {
|
||||
setError(err.message || `Failed to switch to ${network.chainName}.`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
try {
|
||||
await ethereum.request({ method: 'wallet_addEthereumChain', params: [chainParamsForWallet(network)] })
|
||||
return true
|
||||
} catch (e) {
|
||||
const err = e as { message?: string }
|
||||
setError(err.message || `Failed to add ${network.chainName}.`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const watchTokensSequentially = async (
|
||||
chainId: number,
|
||||
tokens: TokenListToken[],
|
||||
label: string,
|
||||
startIndex = 0,
|
||||
priorAdded = 0,
|
||||
) => {
|
||||
setError(null)
|
||||
if (startIndex === 0) {
|
||||
setStatus(null)
|
||||
setPendingFlow(null)
|
||||
}
|
||||
setProgress(null)
|
||||
|
||||
const ethereum = resolveEthereum()
|
||||
if (!ethereum) {
|
||||
setError('No wallet provider found. Use MetaMask mobile in-app browser or WalletConnect.')
|
||||
return
|
||||
}
|
||||
|
||||
const row = chains.find((entry) => entry.chainId === chainId)
|
||||
if (!row?.network) {
|
||||
setError(`Network metadata for ${chainLabel(chainId)} is not available yet.`)
|
||||
return
|
||||
}
|
||||
|
||||
const switched = await switchOrAddChain(row.network)
|
||||
if (!switched) return
|
||||
|
||||
const validTokens = dedupeWalletWatchTokens(tokens.filter(isTokenListToken))
|
||||
if (validTokens.length === 0) {
|
||||
setError(`No token metadata is available for ${chainLabel(chainId)}.`)
|
||||
return
|
||||
}
|
||||
|
||||
const batch = await runWatchAssetBatch(ethereum, validTokens, startIndex, {
|
||||
mobile: mobileWalletContext,
|
||||
onProgress: (current, total) => setProgress({ current, total, chainId }),
|
||||
})
|
||||
|
||||
const totalAdded = priorAdded + batch.addedCount
|
||||
setProgress(null)
|
||||
|
||||
if (batch.stoppedEarly) {
|
||||
setError(batch.errorMessage || 'Token import stopped.')
|
||||
setPendingFlow(null)
|
||||
setStatus(`${totalAdded} of ${validTokens.length} ${label} requests were accepted before the flow stopped.`)
|
||||
return
|
||||
}
|
||||
|
||||
if (batch.nextIndex < validTokens.length) {
|
||||
setPendingFlow({
|
||||
chainId,
|
||||
tokens: validTokens,
|
||||
label,
|
||||
nextIndex: batch.nextIndex,
|
||||
totalAdded,
|
||||
})
|
||||
setStatus(
|
||||
`${totalAdded} of ${validTokens.length} on ${chainLabel(chainId)}. Tap Continue on that chain card for the next mobile prompts.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
setPendingFlow(null)
|
||||
setStatus(`${totalAdded} of ${validTokens.length} ${label} token requests were accepted on ${chainLabel(chainId)}.`)
|
||||
}
|
||||
|
||||
const continuePendingFlow = async () => {
|
||||
if (!pendingFlow) return
|
||||
const { chainId, tokens, label, nextIndex, totalAdded } = pendingFlow
|
||||
await watchTokensSequentially(chainId, tokens, label, nextIndex, totalAdded)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-6 space-y-4 rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800 sm:p-5">
|
||||
<h2 className="text-lg font-semibold">Multi-chain token import</h2>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Add canonical tokens on other supported networks. Each chain switches your wallet first, then runs sequential
|
||||
EIP-747 <code className="rounded bg-gray-100 px-1 text-xs dark:bg-gray-900">wallet_watchAsset</code> prompts.
|
||||
{mobileWalletContext ? ' On mobile, two prompts run per tap — use Continue on the chain card.' : null}
|
||||
</p>
|
||||
|
||||
{pendingFlow ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void continuePendingFlow()}
|
||||
className={`rounded bg-amber-600 px-4 py-2 text-sm font-medium text-white hover:bg-amber-700 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Continue {chainLabel(pendingFlow.chainId)} ({pendingFlow.totalAdded} added, {pendingFlow.tokens.length - pendingFlow.nextIndex} left)
|
||||
</button>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
{chains.map((row) => {
|
||||
const featured = featuredByChain.get(row.chainId) ?? []
|
||||
return (
|
||||
<div key={row.chainId} className="rounded-lg border border-gray-200 p-4 dark:border-gray-700">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">{chainLabel(row.chainId)}</div>
|
||||
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{row.loading
|
||||
? 'Loading token metadata…'
|
||||
: row.error
|
||||
? row.error
|
||||
: `${row.tokens.length} canonical tokens · ${featured.length} featured`}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!row.network || row.loading}
|
||||
onClick={() => row.network && void switchOrAddChain(row.network).then((ok) => ok && setStatus(`Switched to ${chainLabel(row.chainId)}.`))}
|
||||
className={`rounded bg-gray-600 px-3 py-2 text-xs font-medium text-white hover:bg-gray-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Switch chain
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={row.loading || featured.length === 0 || pendingFlow !== null}
|
||||
onClick={() => void watchTokensSequentially(row.chainId, featured, `featured ${chainLabel(row.chainId)}`)}
|
||||
className={`rounded bg-primary-600 px-3 py-2 text-xs font-medium text-white hover:bg-primary-700 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add featured
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={row.loading || row.tokens.length === 0 || pendingFlow !== null}
|
||||
onClick={() => void watchTokensSequentially(row.chainId, row.tokens, chainLabel(row.chainId))}
|
||||
className={`rounded bg-gray-700 px-3 py-2 text-xs font-medium text-white hover:bg-gray-800 disabled:opacity-50 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
Add all
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{progress?.chainId === row.chainId ? (
|
||||
<div className="mt-3 text-xs text-gray-600 dark:text-gray-400">
|
||||
Prompt {progress.current} of {progress.total}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{status ? <p className="text-sm text-green-600 dark:text-green-400">{status}</p> : null}
|
||||
{error ? <p className="text-sm text-red-600 dark:text-red-400">{error}</p> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
116
frontend/src/components/wallet/WalletFundedTokenListing.tsx
Normal file
116
frontend/src/components/wallet/WalletFundedTokenListing.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import type { FundedWalletTokenRow } from '@/utils/walletFundedTokenListing'
|
||||
import {
|
||||
formatFundedRowBalanceUsd,
|
||||
formatFundedRowUnitPrice,
|
||||
} from '@/utils/walletFundedTokenListing'
|
||||
|
||||
function formatLiquidityUsd(value: number | undefined): string {
|
||||
if (value == null || !Number.isFinite(value)) return '—'
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: 'USD',
|
||||
maximumFractionDigits: value >= 100 ? 0 : 2,
|
||||
}).format(value)
|
||||
}
|
||||
|
||||
type WalletFundedTokenListingProps = {
|
||||
rows: FundedWalletTokenRow[]
|
||||
walletAddress?: string | null
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
}
|
||||
|
||||
export default function WalletFundedTokenListing({
|
||||
rows,
|
||||
walletAddress,
|
||||
loading = false,
|
||||
error = null,
|
||||
}: WalletFundedTokenListingProps) {
|
||||
const erc20Count = rows.filter((row) => row.kind === 'erc20').length
|
||||
const hasNative = rows.some((row) => row.kind === 'native')
|
||||
|
||||
return (
|
||||
<div className="mt-4 rounded-lg border border-emerald-200 bg-emerald-50/40 p-4 dark:border-emerald-900/50 dark:bg-emerald-950/20">
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">Funded Chain 138 holdings</div>
|
||||
<p className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Native ETH appears first with the canonical ETH logo. ERC-20 rows use explorer market-batch pricing (unit price,
|
||||
balance USD, visible liquidity). MetaMask may still show "-" for custom tokens until upstream price
|
||||
providers list Chain 138 assets.
|
||||
</p>
|
||||
|
||||
{walletAddress ? (
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Wallet {walletAddress.slice(0, 6)}…{walletAddress.slice(-4)} · {hasNative ? 'ETH + ' : ''}
|
||||
{erc20Count} ERC-20{erc20Count === 1 ? '' : 's'} with balance
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">Loading on-chain balances and market prices…</p>
|
||||
) : null}
|
||||
{error ? <p className="mt-3 text-sm text-red-700 dark:text-red-300">{error}</p> : null}
|
||||
|
||||
{!loading && rows.length === 0 && !error ? (
|
||||
<p className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
No funded catalog tokens found yet. Connect MetaMask and refresh this list.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{rows.length > 0 ? (
|
||||
<div className="mt-4 overflow-x-auto">
|
||||
<table className="min-w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-emerald-200/80 text-xs uppercase tracking-wide text-gray-500 dark:border-emerald-900/60 dark:text-gray-400">
|
||||
<th className="px-2 py-2 font-semibold">Token</th>
|
||||
<th className="px-2 py-2 font-semibold">Balance</th>
|
||||
<th className="px-2 py-2 font-semibold">Unit price</th>
|
||||
<th className="px-2 py-2 font-semibold">Balance USD</th>
|
||||
<th className="px-2 py-2 font-semibold">Liquidity</th>
|
||||
<th className="px-2 py-2 font-semibold">MetaMask</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr
|
||||
key={row.kind === 'native' ? 'native-eth' : row.address!}
|
||||
className="border-b border-emerald-100/80 dark:border-emerald-900/40"
|
||||
>
|
||||
<td className="px-2 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={row.logoURI}
|
||||
alt=""
|
||||
width={32}
|
||||
height={32}
|
||||
className="h-8 w-8 rounded-full border border-gray-200 bg-white object-contain dark:border-gray-700"
|
||||
/>
|
||||
<div>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">
|
||||
{row.symbol}
|
||||
{row.kind === 'native' ? (
|
||||
<span className="ml-2 text-xs font-normal text-emerald-700 dark:text-emerald-300">
|
||||
native
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{row.name}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-3 font-medium text-gray-900 dark:text-white">{row.balanceLabel}</td>
|
||||
<td className="px-2 py-3 text-gray-700 dark:text-gray-300">{formatFundedRowUnitPrice(row)}</td>
|
||||
<td className="px-2 py-3 text-gray-700 dark:text-gray-300">{formatFundedRowBalanceUsd(row)}</td>
|
||||
<td className="px-2 py-3 text-gray-700 dark:text-gray-300">{formatLiquidityUsd(row.liquidityUsd)}</td>
|
||||
<td className="px-2 py-3 text-xs text-gray-600 dark:text-gray-400">
|
||||
{row.kind === 'native' ? 'Automatic on Chain 138' : 'EIP-747 add'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import LpPositionPanel from '@/components/wallet/LpPositionPanel'
|
||||
import type {
|
||||
CapabilitiesCatalog,
|
||||
FetchMetadata,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
toggleWatchlistEntry,
|
||||
writeWatchlistToStorage,
|
||||
} from '@/utils/watchlist'
|
||||
import { MOBILE_WALLET_BUTTON_CLASS } from '@/utils/walletProviderEnv'
|
||||
|
||||
interface WalletPageProps {
|
||||
initialNetworks?: NetworksCatalog | null
|
||||
@@ -142,6 +144,11 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
? isWatchlistEntry(watchlistEntries, walletSession.address)
|
||||
: false
|
||||
|
||||
const lpHintAddresses = useMemo(
|
||||
() => tokenBalances.map((balance) => balance.token_address).filter(Boolean),
|
||||
[tokenBalances],
|
||||
)
|
||||
|
||||
const loadWalletSnapshot = useCallback(async (address: string) => {
|
||||
const [infoResponse, transactionsResponse, balancesResponse, transfersResponse] = await Promise.all([
|
||||
addressesApi.getSafe(138, address),
|
||||
@@ -310,7 +317,7 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
type="button"
|
||||
onClick={() => void handleConnectWallet()}
|
||||
disabled={connectingWallet}
|
||||
className="rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-70"
|
||||
className={`rounded-lg bg-primary-600 px-4 py-3 text-sm font-semibold text-white hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-70 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
{connectingWallet ? 'Connecting wallet…' : 'Connect wallet'}
|
||||
</button>
|
||||
@@ -323,7 +330,7 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
? 'Pair a mobile wallet via WalletConnect QR'
|
||||
: 'Set WALLETCONNECT_PROJECT_ID on the explorer backend to enable WalletConnect'
|
||||
}
|
||||
className="rounded-lg border border-indigo-300 px-3 py-2 text-sm font-semibold text-indigo-700 hover:bg-indigo-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-60 dark:border-indigo-800 dark:text-indigo-300 dark:hover:bg-indigo-950/20"
|
||||
className={`inline-flex items-center justify-center rounded-lg border border-indigo-300 px-4 py-3 text-sm font-semibold text-indigo-700 hover:bg-indigo-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-60 dark:border-indigo-800 dark:text-indigo-300 dark:hover:bg-indigo-950/20 ${MOBILE_WALLET_BUTTON_CLASS}`}
|
||||
>
|
||||
{connectingWalletConnect ? 'Opening WalletConnect…' : 'WalletConnect'}
|
||||
</button>
|
||||
@@ -518,6 +525,15 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<LpPositionPanel
|
||||
chainId={138}
|
||||
address={walletSession.address}
|
||||
hintAddresses={lpHintAddresses}
|
||||
title="LP positions (Chain 138)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user