feat(freshness): enhance diagnostics and update snapshot structure
- Introduced a new Diagnostics struct to capture transaction visibility state and activity state. - Updated BuildSnapshot function to return diagnostics alongside snapshot, completeness, and sampling. - Enhanced test cases to validate the new diagnostics data. - Updated frontend components to utilize the new diagnostics information for improved user feedback on freshness context. This change improves the observability of transaction activity and enhances the user experience by providing clearer insights into the freshness of data.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import type {
|
||||
CapabilitiesCatalog,
|
||||
FetchMetadata,
|
||||
@@ -7,6 +8,15 @@ import type {
|
||||
import { AddToMetaMask } from '@/components/wallet/AddToMetaMask'
|
||||
import Link from 'next/link'
|
||||
import { Explain, useUiMode } from '@/components/common/UiModeContext'
|
||||
import { accessApi, type WalletAccessSession } from '@/services/api/access'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import { addressesApi, type AddressInfo, type TransactionSummary } from '@/services/api/addresses'
|
||||
import {
|
||||
isWatchlistEntry,
|
||||
readWatchlistFromStorage,
|
||||
toggleWatchlistEntry,
|
||||
writeWatchlistToStorage,
|
||||
} from '@/utils/watchlist'
|
||||
|
||||
interface WalletPageProps {
|
||||
initialNetworks?: NetworksCatalog | null
|
||||
@@ -17,8 +27,111 @@ interface WalletPageProps {
|
||||
initialCapabilitiesMeta?: FetchMetadata | null
|
||||
}
|
||||
|
||||
function shortAddress(value?: string | null): string {
|
||||
if (!value) return 'Unknown'
|
||||
if (value.length <= 14) return value
|
||||
return `${value.slice(0, 6)}...${value.slice(-4)}`
|
||||
}
|
||||
|
||||
export default function WalletPage(props: WalletPageProps) {
|
||||
const { mode } = useUiMode()
|
||||
const [walletSession, setWalletSession] = useState<WalletAccessSession | null>(null)
|
||||
const [connectingWallet, setConnectingWallet] = useState(false)
|
||||
const [walletError, setWalletError] = useState<string | null>(null)
|
||||
const [copiedAddress, setCopiedAddress] = useState(false)
|
||||
const [watchlistEntries, setWatchlistEntries] = useState<string[]>([])
|
||||
const [addressInfo, setAddressInfo] = useState<AddressInfo | null>(null)
|
||||
const [recentAddressTransactions, setRecentAddressTransactions] = useState<TransactionSummary[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
const syncSession = () => {
|
||||
setWalletSession(accessApi.getStoredWalletSession())
|
||||
}
|
||||
|
||||
const syncWatchlist = () => {
|
||||
setWatchlistEntries(readWatchlistFromStorage(window.localStorage))
|
||||
}
|
||||
|
||||
syncSession()
|
||||
syncWatchlist()
|
||||
window.addEventListener('explorer-access-session-changed', syncSession)
|
||||
window.addEventListener('storage', syncWatchlist)
|
||||
return () => {
|
||||
window.removeEventListener('explorer-access-session-changed', syncSession)
|
||||
window.removeEventListener('storage', syncWatchlist)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleConnectWallet = async () => {
|
||||
setConnectingWallet(true)
|
||||
setWalletError(null)
|
||||
try {
|
||||
const session = await accessApi.connectWalletSession()
|
||||
setWalletSession(session)
|
||||
} catch (error) {
|
||||
setWalletError(error instanceof Error ? error.message : 'Wallet connection failed.')
|
||||
} finally {
|
||||
setConnectingWallet(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDisconnectWallet = () => {
|
||||
accessApi.clearSession()
|
||||
accessApi.clearWalletSession()
|
||||
setWalletSession(null)
|
||||
}
|
||||
|
||||
const handleCopyAddress = async () => {
|
||||
if (!walletSession?.address || typeof navigator === 'undefined' || !navigator.clipboard) return
|
||||
await navigator.clipboard.writeText(walletSession.address)
|
||||
setCopiedAddress(true)
|
||||
window.setTimeout(() => setCopiedAddress(false), 1500)
|
||||
}
|
||||
|
||||
const handleToggleWatchlist = () => {
|
||||
if (!walletSession?.address || typeof window === 'undefined') return
|
||||
const nextEntries = toggleWatchlistEntry(watchlistEntries, walletSession.address)
|
||||
writeWatchlistToStorage(window.localStorage, nextEntries)
|
||||
setWatchlistEntries(nextEntries)
|
||||
}
|
||||
|
||||
const isSavedToWatchlist = walletSession?.address
|
||||
? isWatchlistEntry(watchlistEntries, walletSession.address)
|
||||
: false
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
if (!walletSession?.address) {
|
||||
setAddressInfo(null)
|
||||
setRecentAddressTransactions([])
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
addressesApi.getSafe(138, walletSession.address),
|
||||
addressesApi.getTransactionsSafe(138, walletSession.address, 1, 3),
|
||||
])
|
||||
.then(([infoResponse, transactionsResponse]) => {
|
||||
if (cancelled) return
|
||||
setAddressInfo(infoResponse.ok ? infoResponse.data : null)
|
||||
setRecentAddressTransactions(transactionsResponse.ok ? transactionsResponse.data : [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setAddressInfo(null)
|
||||
setRecentAddressTransactions([])
|
||||
})
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [walletSession?.address])
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-6 sm:py-8">
|
||||
<h1 className="mb-4 text-2xl font-bold sm:text-3xl">Wallet Tools</h1>
|
||||
@@ -27,6 +140,189 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
? 'Use the explorer-served network catalog, token list, and capability metadata to connect Chain 138 (DeFi Oracle Meta Mainnet) and Ethereum Mainnet to MetaMask and other Web3 wallets.'
|
||||
: 'Use explorer-served network and token metadata to connect Chain 138 and Ethereum Mainnet wallets.'}
|
||||
</p>
|
||||
<div className="mb-6 rounded-2xl border border-sky-200 bg-sky-50/60 p-5 dark:border-sky-900/40 dark:bg-sky-950/20">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="text-lg font-semibold text-gray-900 dark:text-white">Wallet session</div>
|
||||
<p className="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||
{walletSession
|
||||
? mode === 'guided'
|
||||
? 'This wallet is connected to the same account/access session used by the header. You can jump straight into your explorer address view or the access console from here.'
|
||||
: 'Connected wallet session is active for explorer and access surfaces.'
|
||||
: mode === 'guided'
|
||||
? 'Connect a browser wallet to make this page useful beyond setup: copy your address, open your on-explorer address page, and continue into the access console with the same session.'
|
||||
: 'Connect a wallet to activate account-linked explorer actions.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<EntityBadge label={walletSession ? 'connected' : 'disconnected'} tone={walletSession ? 'success' : 'neutral'} />
|
||||
{walletSession ? <EntityBadge label={walletSession.track} tone="info" /> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<div className="rounded-2xl border border-white/60 bg-white/70 p-4 dark:border-white/10 dark:bg-black/10">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Current wallet</div>
|
||||
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{walletSession ? shortAddress(walletSession.address) : 'No wallet connected'}
|
||||
</div>
|
||||
<div className="mt-1 break-all text-sm text-gray-600 dark:text-gray-400">
|
||||
{walletSession?.address || 'Use Connect Wallet to start a browser-wallet session.'}
|
||||
</div>
|
||||
{walletSession?.expiresAt ? (
|
||||
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
Session expires {new Date(walletSession.expiresAt).toLocaleString()}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/60 bg-white/70 p-4 dark:border-white/10 dark:bg-black/10">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Quick actions</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{walletSession ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyAddress}
|
||||
className="rounded-lg border border-primary-300 px-3 py-2 text-sm font-medium text-primary-700 hover:bg-primary-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-primary-800 dark:text-primary-300 dark:hover:bg-primary-950/20"
|
||||
>
|
||||
{copiedAddress ? 'Address copied' : 'Copy address'}
|
||||
</button>
|
||||
<Link
|
||||
href={`/addresses/${walletSession.address}`}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900/60"
|
||||
>
|
||||
Open address
|
||||
</Link>
|
||||
<Link
|
||||
href="/access"
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900/60"
|
||||
>
|
||||
Open access console
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleWatchlist}
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900/60"
|
||||
>
|
||||
{isSavedToWatchlist ? 'Remove from watchlist' : 'Save to watchlist'}
|
||||
</button>
|
||||
<Link
|
||||
href="/watchlist"
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900/60"
|
||||
>
|
||||
Open watchlist
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDisconnectWallet}
|
||||
className="rounded-lg border border-red-200 px-3 py-2 text-sm font-medium text-red-700 hover:bg-red-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 dark:border-red-900/60 dark:text-red-200 dark:hover:bg-red-950/30"
|
||||
>
|
||||
Disconnect wallet
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={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"
|
||||
>
|
||||
{connectingWallet ? 'Connecting wallet…' : 'Connect wallet'}
|
||||
</button>
|
||||
<Link
|
||||
href="/access"
|
||||
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900/60"
|
||||
>
|
||||
Open access console
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{walletError ? (
|
||||
<div className="mt-3 text-sm text-red-700 dark:text-red-300">{walletError}</div>
|
||||
) : null}
|
||||
{walletSession ? (
|
||||
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
{isSavedToWatchlist
|
||||
? 'This wallet is already saved in the shared explorer watchlist.'
|
||||
: 'Save this wallet into the shared explorer watchlist to revisit it from addresses and transaction workflows.'}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{walletSession ? (
|
||||
<div className="mt-4 rounded-2xl border border-white/60 bg-white/70 p-4 dark:border-white/10 dark:bg-black/10">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
Connected Address Snapshot
|
||||
</div>
|
||||
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{mode === 'guided'
|
||||
? 'A quick explorer view of the connected wallet so you can jump from connection into browsing and monitoring.'
|
||||
: 'Current explorer snapshot for the connected wallet.'}
|
||||
</div>
|
||||
</div>
|
||||
<Link href={`/addresses/${walletSession.address}`} className="text-sm font-medium text-primary-600 hover:underline">
|
||||
Open full address page →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Transactions</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{addressInfo ? addressInfo.transaction_count.toLocaleString() : 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Token Holdings</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{addressInfo ? addressInfo.token_count.toLocaleString() : 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Address Type</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{addressInfo ? (addressInfo.is_contract ? 'Contract' : 'EOA') : 'Unknown'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Recent Indexed Tx</div>
|
||||
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{recentAddressTransactions[0] ? `#${recentAddressTransactions[0].block_number}` : 'None visible'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-3 lg:grid-cols-3">
|
||||
{recentAddressTransactions.length === 0 ? (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 lg:col-span-3">
|
||||
No recent indexed transactions are currently visible for this connected wallet.
|
||||
</div>
|
||||
) : (
|
||||
recentAddressTransactions.map((transaction) => (
|
||||
<Link
|
||||
key={transaction.hash}
|
||||
href={`/transactions/${transaction.hash}`}
|
||||
className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 text-sm hover:border-primary-300 hover:bg-primary-50/60 dark:border-gray-800 dark:bg-gray-900/40 dark:hover:border-primary-700 dark:hover:bg-primary-950/20"
|
||||
>
|
||||
<div className="font-semibold text-gray-900 dark:text-white">
|
||||
{transaction.hash.slice(0, 10)}...{transaction.hash.slice(-8)}
|
||||
</div>
|
||||
<div className="mt-1 text-gray-600 dark:text-gray-400">
|
||||
Block #{transaction.block_number.toLocaleString()}
|
||||
</div>
|
||||
</Link>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<AddToMetaMask {...props} />
|
||||
<div className="mt-6 rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
|
||||
<Explain>
|
||||
|
||||
Reference in New Issue
Block a user