feat(explorer): dynamic feeds, wallet SSR alignment, and detail pagination
Align wallet SSR with report token-list, dedupe featured v1 tokens, refresh home and wallet snapshots on a 60s cadence, and drive vanilla SPA chain add/watch from API metadata. Add shared pagination/tabs for address, token, and transaction pages, extend token aggregation helpers, and harden stats API with tests and health checks. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/explorer/backend/api/freshness"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type explorerStats struct {
|
||||
@@ -34,6 +35,14 @@ type explorerGasPrices struct {
|
||||
|
||||
type statsQueryFunc = freshness.QueryRowFunc
|
||||
|
||||
type statsErrorRow struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (r statsErrorRow) Scan(dest ...any) error {
|
||||
return r.err
|
||||
}
|
||||
|
||||
func queryNullableFloat64(ctx context.Context, queryRow statsQueryFunc, query string, args ...any) (*float64, error) {
|
||||
var value sql.NullFloat64
|
||||
if err := queryRow(ctx, query, args...).Scan(&value); err != nil {
|
||||
@@ -191,23 +200,72 @@ func loadExplorerStats(ctx context.Context, chainID int, queryRow statsQueryFunc
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func loadExplorerStatsFallback(ctx context.Context, chainID int, cause error) explorerStats {
|
||||
rpcURL := strings.TrimSpace(os.Getenv("RPC_URL"))
|
||||
now := time.Now().UTC()
|
||||
queryErr := fmt.Errorf("blockscout database unavailable")
|
||||
if cause != nil {
|
||||
queryErr = cause
|
||||
}
|
||||
queryRow := func(context.Context, string, ...any) pgx.Row {
|
||||
return statsErrorRow{err: queryErr}
|
||||
}
|
||||
|
||||
snapshot, completeness, sampling, diagnostics, err := freshness.BuildSnapshot(
|
||||
ctx,
|
||||
chainID,
|
||||
queryRow,
|
||||
func(ctx context.Context) (*freshness.Reference, error) {
|
||||
return freshness.ProbeChainHead(ctx, rpcURL)
|
||||
},
|
||||
now,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
if sampling.Issues == nil {
|
||||
sampling.Issues = map[string]string{}
|
||||
}
|
||||
sampling.Issues["fallback_freshness"] = err.Error()
|
||||
}
|
||||
if sampling.Issues == nil {
|
||||
sampling.Issues = map[string]string{}
|
||||
}
|
||||
if cause != nil {
|
||||
sampling.Issues["stats_database"] = cause.Error()
|
||||
}
|
||||
|
||||
stats := explorerStats{
|
||||
Freshness: snapshot,
|
||||
Completeness: completeness,
|
||||
Sampling: sampling,
|
||||
Diagnostics: diagnostics,
|
||||
}
|
||||
if snapshot.ChainHead.BlockNumber != nil {
|
||||
stats.LatestBlock = *snapshot.ChainHead.BlockNumber
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
// handleStats handles GET /api/v2/stats
|
||||
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
if !s.requireDB(w) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stats, err := loadExplorerStats(ctx, s.chainID, s.db.QueryRow)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "explorer stats are temporarily unavailable")
|
||||
return
|
||||
var stats explorerStats
|
||||
if s.db == nil {
|
||||
stats = loadExplorerStatsFallback(ctx, s.chainID, fmt.Errorf("database pool is not configured"))
|
||||
} else {
|
||||
var err error
|
||||
stats, err = loadExplorerStats(ctx, s.chainID, s.db.QueryRow)
|
||||
if err != nil {
|
||||
stats = loadExplorerStatsFallback(ctx, s.chainID, err)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -136,3 +136,33 @@ func TestLoadExplorerStatsReturnsErrorWhenQueryFails(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "query total transactions")
|
||||
}
|
||||
|
||||
func TestLoadExplorerStatsFallbackUsesRPCHead(t *testing.T) {
|
||||
rpc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Method string `json:"method"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch req.Method {
|
||||
case "eth_blockNumber":
|
||||
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x4d2"}`))
|
||||
case "eth_getBlockByNumber":
|
||||
ts := time.Now().Add(-3 * time.Second).Unix()
|
||||
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"timestamp":"0x` + strconv.FormatInt(ts, 16) + `"}}`))
|
||||
default:
|
||||
http.Error(w, `{"jsonrpc":"2.0","id":1,"error":{"message":"unsupported"}}`, http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer rpc.Close()
|
||||
t.Setenv("RPC_URL", rpc.URL)
|
||||
|
||||
stats := loadExplorerStatsFallback(context.Background(), 138, errors.New("database down"))
|
||||
|
||||
require.Equal(t, int64(1234), stats.LatestBlock)
|
||||
require.NotNil(t, stats.Freshness.ChainHead.BlockNumber)
|
||||
require.Equal(t, int64(1234), *stats.Freshness.ChainHead.BlockNumber)
|
||||
require.Equal(t, freshness.CompletenessUnavailable, stats.Completeness.TransactionsFeed)
|
||||
require.Contains(t, stats.Sampling.Issues, "stats_database")
|
||||
require.Contains(t, stats.Sampling.Issues["latest_indexed_block"], "database down")
|
||||
}
|
||||
|
||||
@@ -11,6 +11,15 @@ describe('resolveExplorerApiBase', () => {
|
||||
).toBe('https://blockscout.defi-oracle.io')
|
||||
})
|
||||
|
||||
it('uses browser HTTPS origin when an explicit same-host HTTP value is present', () => {
|
||||
expect(
|
||||
resolveExplorerApiBase({
|
||||
envValue: 'http://explorer.d-bis.org',
|
||||
browserOrigin: 'https://explorer.d-bis.org',
|
||||
})
|
||||
).toBe('https://explorer.d-bis.org')
|
||||
})
|
||||
|
||||
it('falls back to same-origin in the browser when env is empty', () => {
|
||||
expect(
|
||||
resolveExplorerApiBase({
|
||||
|
||||
@@ -4,19 +4,35 @@ function normalizeApiBase(value: string | null | undefined): string {
|
||||
return (value || '').trim().replace(/\/$/, '')
|
||||
}
|
||||
|
||||
function preferBrowserOriginForSameHost(explicitBase: string, browserOrigin: string): string {
|
||||
if (!explicitBase || !browserOrigin) return explicitBase
|
||||
|
||||
try {
|
||||
const explicitUrl = new URL(explicitBase)
|
||||
const browserUrl = new URL(browserOrigin)
|
||||
if (explicitUrl.hostname === browserUrl.hostname && explicitUrl.protocol !== browserUrl.protocol) {
|
||||
return browserOrigin
|
||||
}
|
||||
} catch {
|
||||
return explicitBase
|
||||
}
|
||||
|
||||
return explicitBase
|
||||
}
|
||||
|
||||
export function resolveExplorerApiBase(options: {
|
||||
envValue?: string | null
|
||||
browserOrigin?: string | null
|
||||
serverFallback?: string
|
||||
} = {}): string {
|
||||
const explicitBase = normalizeApiBase(options.envValue ?? process.env.NEXT_PUBLIC_API_URL ?? '')
|
||||
if (explicitBase) {
|
||||
return explicitBase
|
||||
}
|
||||
|
||||
const browserOrigin = normalizeApiBase(
|
||||
options.browserOrigin ?? (typeof window !== 'undefined' ? window.location.origin : '')
|
||||
)
|
||||
if (explicitBase) {
|
||||
return preferBrowserOriginForSameHost(explicitBase, browserOrigin)
|
||||
}
|
||||
|
||||
if (browserOrigin) {
|
||||
return browserOrigin
|
||||
}
|
||||
|
||||
@@ -2100,6 +2100,47 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchChain138AddEthereumChainPayload() {
|
||||
if (window._chain138AddEthereumChainCache) return window._chain138AddEthereumChainCache;
|
||||
try {
|
||||
var res = await fetch('/api/v1/config/metamask?chainId=138', { cache: 'no-store' });
|
||||
if (res.ok) {
|
||||
var data = await res.json();
|
||||
if (data && data.addEthereumChain) {
|
||||
window._chain138AddEthereumChainCache = data.addEthereumChain;
|
||||
return window._chain138AddEthereumChainCache;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchChain138ReportTokenList() {
|
||||
if (window._chain138TokenListCache) return window._chain138TokenListCache;
|
||||
try {
|
||||
var res = await fetch('/api/v1/report/token-list?chainId=138', { cache: 'no-store' });
|
||||
if (res.ok) {
|
||||
var data = await res.json();
|
||||
window._chain138TokenListCache = data && Array.isArray(data.tokens) ? data.tokens : [];
|
||||
return window._chain138TokenListCache;
|
||||
}
|
||||
} catch (e) {}
|
||||
return [];
|
||||
}
|
||||
|
||||
async function resolveTokenLogoUri(address) {
|
||||
if (!address) return undefined;
|
||||
var tokens = await fetchChain138ReportTokenList();
|
||||
var normalized = String(address).toLowerCase();
|
||||
for (var i = 0; i < tokens.length; i++) {
|
||||
var token = tokens[i];
|
||||
if (token && token.address && String(token.address).toLowerCase() === normalized) {
|
||||
return token.logoURI || undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function addTokenToWallet(address, symbol, decimals, name) {
|
||||
if (!address || !/^0x[a-fA-F0-9]{40}$/i.test(address)) {
|
||||
if (typeof showToast === 'function') showToast('Invalid token address', 'error');
|
||||
@@ -2122,6 +2163,7 @@
|
||||
if (typeof showToast === 'function') showToast('This token has no symbol on-chain. Add it manually in MetaMask: use this contract address and set symbol to WETH.', 'info');
|
||||
return;
|
||||
}
|
||||
var logoURI = await resolveTokenLogoUri(address);
|
||||
var added = await window.ethereum.request({
|
||||
method: 'wallet_watchAsset',
|
||||
params: {
|
||||
@@ -2131,7 +2173,7 @@
|
||||
symbol: (useSymbol !== undefined && useSymbol !== null) ? useSymbol : 'TOKEN',
|
||||
decimals: useDecimals,
|
||||
name: useName || undefined,
|
||||
image: undefined
|
||||
image: logoURI
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -2252,18 +2294,19 @@
|
||||
// If chain doesn't exist, add it
|
||||
if (switchError.code === 4902) {
|
||||
try {
|
||||
var chainPayload = await fetchChain138AddEthereumChainPayload();
|
||||
await window.ethereum.request({
|
||||
method: 'wallet_addEthereumChain',
|
||||
params: [{
|
||||
params: [chainPayload || {
|
||||
chainId,
|
||||
chainName: 'Chain 138',
|
||||
chainName: 'DeFi Oracle Meta Mainnet',
|
||||
nativeCurrency: {
|
||||
name: 'ETH',
|
||||
name: 'Ether',
|
||||
symbol: 'ETH',
|
||||
decimals: 18
|
||||
},
|
||||
rpcUrls: RPC_URLS.length > 0 ? RPC_URLS : [RPC_URL],
|
||||
blockExplorerUrls: [window.location.origin || 'https://blockscout.defi-oracle.io']
|
||||
blockExplorerUrls: ['https://explorer.d-bis.org', 'https://blockscout.defi-oracle.io']
|
||||
}],
|
||||
});
|
||||
} catch (addError) {
|
||||
@@ -5314,17 +5357,26 @@
|
||||
const WETH9_BRIDGE_138 = '0xcacfd227A040002e49e2e01626363071324f820a';
|
||||
const WETH10_BRIDGE_138 = '0xe0E93247376aa097dB308B92e6Ba36bA015535D0';
|
||||
|
||||
// Ethereum Mainnet Bridge Contracts
|
||||
const WETH9_BRIDGE_MAINNET = '0xc9901ce2Ddb6490FAA183645147a87496d8b20B6';
|
||||
// Ethereum Mainnet bridge references (relay release + CCIP hubs)
|
||||
const WETH9_BRIDGE_MAINNET_RELAY = '0xF9A32F37099c582D28b4dE7Fca6eaC1e5259f939';
|
||||
const WETH9_BRIDGE_MAINNET_HUB = '0xc9901ce2Ddb6490FAA183645147a87496d8b20B6';
|
||||
const WETH10_BRIDGE_MAINNET = '0x04E1e22B0D41e99f4275bd40A50480219bc9A223';
|
||||
const CW_BRIDGE_MAINNET = '0x2bF74583206A49Be07E0E8A94197C12987AbD7B5';
|
||||
const CW_L1_BRIDGE_138 = '0x152ed3e9912161b76bdfd368d0c84b7c31c10de7';
|
||||
|
||||
const explorerLinks = {
|
||||
'BSC (56)': { label: 'BscScan', baseUrl: 'https://bscscan.com/address/' },
|
||||
'BNB Chain (56)': { label: 'BscScan', baseUrl: 'https://bscscan.com/address/' },
|
||||
'Polygon (137)': { label: 'PolygonScan', baseUrl: 'https://polygonscan.com/address/' },
|
||||
'Avalanche (43114)': { label: 'Avalanche Explorer', baseUrl: 'https://subnets.avax.network/c-chain/address/' },
|
||||
'Avalanche C-Chain (43114)': { label: 'Avalanche Explorer', baseUrl: 'https://subnets.avax.network/c-chain/address/' },
|
||||
'Base (8453)': { label: 'BaseScan', baseUrl: 'https://basescan.org/address/' },
|
||||
'Arbitrum (42161)': { label: 'Arbiscan', baseUrl: 'https://arbiscan.io/address/' },
|
||||
'Arbitrum One (42161)': { label: 'Arbiscan', baseUrl: 'https://arbiscan.io/address/' },
|
||||
'Optimism (10)': { label: 'Optimistic Etherscan', baseUrl: 'https://optimistic.etherscan.io/address/' },
|
||||
'Gnosis (100)': { label: 'GnosisScan', baseUrl: 'https://gnosisscan.io/address/' },
|
||||
'Cronos (25)': { label: 'Cronoscan', baseUrl: 'https://cronoscan.com/address/' },
|
||||
'Celo (42220)': { label: 'CeloScan', baseUrl: 'https://celoscan.io/address/' },
|
||||
'Ethereum Mainnet (1)': { label: 'Etherscan', baseUrl: 'https://etherscan.io/address/' }
|
||||
};
|
||||
function renderExplorerLink(chainLabel, address) {
|
||||
@@ -5334,27 +5386,49 @@
|
||||
return '<a href="' + escapeHtml(url) + '" target="_blank" rel="noopener noreferrer" style="color: var(--primary); white-space: nowrap;">View on ' + escapeHtml(explorer.label) + '</a>';
|
||||
}
|
||||
|
||||
// Bridge routes configuration
|
||||
const routes = {
|
||||
// Bridge routes — prefer live token-aggregation catalog; static fallback matches config/bridge-routes-chain138-default.json
|
||||
var routes = {
|
||||
weth9: {
|
||||
'BSC (56)': '0x24293CA562aE1100E60a4640FF49bd656cFf93B4',
|
||||
'Ethereum Mainnet (1)': WETH9_BRIDGE_MAINNET_RELAY,
|
||||
'BNB Chain (56)': '0x886C6A4ABC064dbf74E7caEc460b7eeC31F1b78C',
|
||||
'Polygon (137)': '0xF7736443f02913e7e0773052103296CfE1637448',
|
||||
'Avalanche (43114)': '0x24293CA562aE1100E60a4640FF49bd656cFf93B4',
|
||||
'Avalanche C-Chain (43114)': '0x3f8C409C6072a2B6a4Ff17071927bA70F80c725F',
|
||||
'Base (8453)': '0x24293CA562aE1100E60a4640FF49bd656cFf93B4',
|
||||
'Arbitrum (42161)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c',
|
||||
'Arbitrum One (42161)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c',
|
||||
'Optimism (10)': '0x6e94e53F73893b2a6784Df663920D31043A6dE07',
|
||||
'Ethereum Mainnet (1)': WETH9_BRIDGE_MAINNET
|
||||
'Gnosis (100)': '0x4ab39b5BaB7b463435209A9039bd40Cf241F5a82',
|
||||
'Cronos (25)': '0x3Cc23d086fCcbAe1e5f3FE2bA4A263E1D27d8Cab',
|
||||
'Celo (42220)': '0xD3AD6831aacB5386B8A25BB8D8176a6C8a026f04'
|
||||
},
|
||||
weth10: {
|
||||
'BSC (56)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c',
|
||||
'Ethereum Mainnet (1)': WETH10_BRIDGE_MAINNET,
|
||||
'BNB Chain (56)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c',
|
||||
'Polygon (137)': '0x0CA60e6f8589c540200daC9D9Cb27BC2e48eE66A',
|
||||
'Avalanche (43114)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c',
|
||||
'Avalanche C-Chain (43114)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c',
|
||||
'Base (8453)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c',
|
||||
'Arbitrum (42161)': '0x73376eB92c16977B126dB9112936A20Fa0De3442',
|
||||
'Arbitrum One (42161)': '0x73376eB92c16977B126dB9112936A20Fa0De3442',
|
||||
'Optimism (10)': '0x24293CA562aE1100E60a4640FF49bd656cFf93B4',
|
||||
'Ethereum Mainnet (1)': WETH10_BRIDGE_MAINNET
|
||||
'Gnosis (100)': '0xC15ACdBAC59B3C7Cb4Ea4B3D58334A4b143B4b44',
|
||||
'Cronos (25)': '0x73376eB92c16977B126dB9112936A20Fa0De3442',
|
||||
'Celo (42220)': '0xa4B9DD039565AeD9641D45b57061f99d9cA6Df08'
|
||||
}
|
||||
};
|
||||
var bridgeRoutesSource = 'static-fallback';
|
||||
try {
|
||||
var routesResp = await fetch(TOKEN_AGGREGATION_API_BASE + '/v1/bridge/routes', { credentials: 'omit' });
|
||||
if (routesResp.ok) {
|
||||
var routesPayload = await routesResp.json();
|
||||
if (routesPayload && routesPayload.routes && routesPayload.routes.weth9) {
|
||||
routes = routesPayload.routes;
|
||||
bridgeRoutesSource = routesPayload.source || 'token-aggregation';
|
||||
if (routesPayload.chain138Bridges && routesPayload.chain138Bridges.weth9) {
|
||||
// no-op: source addresses already set above from canonical constants
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (routesErr) {
|
||||
console.warn('Bridge routes API unavailable, using static catalog', routesErr);
|
||||
}
|
||||
const sourceBridgeCount = 2;
|
||||
const mainnetBridgeCount = 2;
|
||||
const routeBridgeCount = new Set([
|
||||
@@ -5372,7 +5446,7 @@
|
||||
<div class="chain-name"><i class="fas fa-network-wired"></i> CCIP Bridge Ecosystem</div>
|
||||
<div class="badge badge-info" style="display:inline-flex; margin-top:0.65rem; white-space:nowrap;">${sourceBridgeCount} source contracts / ${mainnetBridgeCount} mainnet contracts / ${routeBridgeCount} unique route contracts</div>
|
||||
<div style="color: var(--text-light); margin-bottom: 1rem;">
|
||||
Cross-chain interoperability powered by Chainlink CCIP
|
||||
Cross-chain interoperability powered by Chainlink CCIP. Route catalog source: <code>${escapeHtml(bridgeRoutesSource)}</code>.
|
||||
</div>
|
||||
<div class="badge badge-warning" style="display:inline-flex; white-space:normal; text-align:left;">
|
||||
Arbitrum remains route-blocked on the current Mainnet hub leg. The latest Mainnet -> Arbitrum WETH9 send reverted before any bridge event was emitted, so treat Arbitrum as unavailable until that hub path is repaired.
|
||||
@@ -5514,16 +5588,19 @@
|
||||
<div class="card" style="margin-bottom: 1.5rem;">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title"><i class="fas fa-ethereum"></i> Ethereum Mainnet Bridges</h3>
|
||||
<span class="badge badge-info" style="white-space: nowrap;">${sourceBridgeCount} source contracts / ${mainnetBridgeCount} mainnet contracts / ${routeBridgeCount} unique route contracts</span>
|
||||
<span class="badge badge-info" style="white-space: nowrap;">Relay + CCIP hubs + cW L2</span>
|
||||
</div>
|
||||
<div class="chain-info" style="grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 1.5rem;">
|
||||
<div class="chain-info" style="grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1.5rem;">
|
||||
<div class="chain-stat" style="background: var(--light); padding: 1rem; border-radius: 8px;">
|
||||
<div class="chain-stat-label" style="font-weight: 600; margin-bottom: 0.5rem;">CCIPWETH9Bridge</div>
|
||||
<div class="chain-stat-label" style="font-weight: 600; margin-bottom: 0.5rem;">CCIPRelayBridge (138→1 WETH9)</div>
|
||||
<div class="chain-stat-value">
|
||||
<span class="hash" onclick="window.open('https://etherscan.io/address/${WETH9_BRIDGE_MAINNET}', '_blank', 'noopener,noreferrer')" style="cursor: pointer; font-size: 0.9rem;">${WETH9_BRIDGE_MAINNET}</span>
|
||||
<span class="hash" onclick="window.open('https://etherscan.io/address/${WETH9_BRIDGE_MAINNET_RELAY}', '_blank', 'noopener,noreferrer')" style="cursor: pointer; font-size: 0.9rem;">${WETH9_BRIDGE_MAINNET_RELAY}</span>
|
||||
</div>
|
||||
<div style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--text-light);">
|
||||
<a href="https://etherscan.io/address/${WETH9_BRIDGE_MAINNET}" target="_blank" rel="noopener noreferrer" style="color: var(--primary);">View on Etherscan</a>
|
||||
</div>
|
||||
<div class="chain-stat" style="background: var(--light); padding: 1rem; border-radius: 8px;">
|
||||
<div class="chain-stat-label" style="font-weight: 600; margin-bottom: 0.5rem;">CCIPWETH9Bridge (hub)</div>
|
||||
<div class="chain-stat-value">
|
||||
<span class="hash" onclick="window.open('https://etherscan.io/address/${WETH9_BRIDGE_MAINNET_HUB}', '_blank', 'noopener,noreferrer')" style="cursor: pointer; font-size: 0.9rem;">${WETH9_BRIDGE_MAINNET_HUB}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chain-stat" style="background: var(--light); padding: 1rem; border-radius: 8px;">
|
||||
@@ -5531,8 +5608,17 @@
|
||||
<div class="chain-stat-value">
|
||||
<span class="hash" onclick="window.open('https://etherscan.io/address/${WETH10_BRIDGE_MAINNET}', '_blank', 'noopener,noreferrer')" style="cursor: pointer; font-size: 0.9rem;">${WETH10_BRIDGE_MAINNET}</span>
|
||||
</div>
|
||||
<div style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--text-light);">
|
||||
<a href="https://etherscan.io/address/${WETH10_BRIDGE_MAINNET}" target="_blank" rel="noopener noreferrer" style="color: var(--primary);">View on Etherscan</a>
|
||||
</div>
|
||||
<div class="chain-stat" style="background: var(--light); padding: 1rem; border-radius: 8px;">
|
||||
<div class="chain-stat-label" style="font-weight: 600; margin-bottom: 0.5rem;">CWMultiTokenBridge (cW*)</div>
|
||||
<div class="chain-stat-value">
|
||||
<span class="hash" onclick="window.open('https://etherscan.io/address/${CW_BRIDGE_MAINNET}', '_blank', 'noopener,noreferrer')" style="cursor: pointer; font-size: 0.9rem;">${CW_BRIDGE_MAINNET}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="chain-stat" style="background: var(--light); padding: 1rem; border-radius: 8px;">
|
||||
<div class="chain-stat-label" style="font-weight: 600; margin-bottom: 0.5rem;">CW L1 bridge (Chain 138)</div>
|
||||
<div class="chain-stat-value">
|
||||
${explorerAddressLink(CW_L1_BRIDGE_138, escapeHtml(CW_L1_BRIDGE_138), 'color: inherit; text-decoration: none; font-size: 0.9rem;')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1645,7 +1645,7 @@
|
||||
<h4 style="margin-top: 1.5rem; margin-bottom: 0.5rem;">Cross-Chain Bridging</h4>
|
||||
<p>Both WETH9 and WETH10 can be bridged to other chains using the CCIP bridge contracts:</p>
|
||||
<ul style="margin-left: 2rem; margin-top: 0.5rem;">
|
||||
<li><strong>WETH9 Bridge:</strong> <span class="hash">0x971cD9D156f193df8051E48043C476e53ECd4693</span></li>
|
||||
<li><strong>WETH9 Bridge:</strong> <a class="hash" href="/addresses/0xcacfd227A040002e49e2e01626363071324f820a" onclick="event.preventDefault(); showAddressDetail('0xcacfd227A040002e49e2e01626363071324f820a')" style="color: inherit; text-decoration: none;">0xcacfd227A040002e49e2e01626363071324f820a</a></li>
|
||||
<li><strong>WETH10 Bridge:</strong> <span class="hash">0xe0E93247376aa097dB308B92e6Ba36bA015535D0</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
60
frontend/src/components/common/PaginationControls.tsx
Normal file
60
frontend/src/components/common/PaginationControls.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
interface PaginationControlsProps {
|
||||
page: number
|
||||
pageCount: number
|
||||
onPageChange: (page: number) => void
|
||||
label?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function PaginationControls({
|
||||
page,
|
||||
pageCount,
|
||||
onPageChange,
|
||||
label = 'Rows',
|
||||
className = '',
|
||||
}: PaginationControlsProps) {
|
||||
if (pageCount <= 1) return null
|
||||
|
||||
const pages = Array.from({ length: pageCount }, (_, index) => index + 1)
|
||||
|
||||
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>
|
||||
<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"
|
||||
>
|
||||
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>
|
||||
))}
|
||||
<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"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
45
frontend/src/components/common/SectionTabs.tsx
Normal file
45
frontend/src/components/common/SectionTabs.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
export interface SectionTab<T extends string> {
|
||||
id: T
|
||||
label: string
|
||||
count?: number
|
||||
}
|
||||
|
||||
interface SectionTabsProps<T extends string> {
|
||||
tabs: SectionTab<T>[]
|
||||
activeTab: T
|
||||
onChange: (tab: T) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export default function SectionTabs<T extends string>({
|
||||
tabs,
|
||||
activeTab,
|
||||
onChange,
|
||||
className = '',
|
||||
}: SectionTabsProps<T>) {
|
||||
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>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -198,6 +198,11 @@ export default function LiquidityOperationsPage({
|
||||
}),
|
||||
[bridgeStatus, stats],
|
||||
)
|
||||
const liquidityInventoryUpdatedAt =
|
||||
stats?.sampling?.stats_generated_at ||
|
||||
stats?.freshness?.chain_head?.timestamp ||
|
||||
routeMatrix?.generatedAt ||
|
||||
routeMatrix?.updated
|
||||
|
||||
const insightLines = useMemo(
|
||||
() => [
|
||||
@@ -234,6 +239,12 @@ export default function LiquidityOperationsPage({
|
||||
href: `/explorer-api/v1/mission-control/liquidity/token/${featuredTokens[0]?.address || '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'}/pools`,
|
||||
notes: 'Cached public pool inventory for a specific Chain 138 token.',
|
||||
},
|
||||
{
|
||||
name: 'External indexer readiness',
|
||||
method: 'GET',
|
||||
href: `/api/v1/report/external-indexer-readiness?chainId=138`,
|
||||
notes: 'One JSON posture for DefiLlama, CoinGecko, CoinMarketCap, and Dexscreener readiness.',
|
||||
},
|
||||
]
|
||||
|
||||
const copyEndpoint = async (endpoint: EndpointCard) => {
|
||||
@@ -321,7 +332,7 @@ export default function LiquidityOperationsPage({
|
||||
</div>
|
||||
<MarketEvidenceNote
|
||||
source="mission-control"
|
||||
lastUpdated={routeMatrix?.updated}
|
||||
lastUpdated={liquidityInventoryUpdatedAt}
|
||||
method="Route matrix, provider capabilities, and mission-control pool inventory are reconciled for visible public liquidity only."
|
||||
compact
|
||||
/>
|
||||
@@ -363,7 +374,7 @@ export default function LiquidityOperationsPage({
|
||||
</div>
|
||||
<MarketEvidenceNote
|
||||
source="mission-control"
|
||||
lastUpdated={routeMatrix?.updated}
|
||||
lastUpdated={liquidityInventoryUpdatedAt}
|
||||
method="Pool TVL is the visible mission-control value for discovered route-backed liquidity."
|
||||
compact
|
||||
/>
|
||||
|
||||
@@ -25,6 +25,12 @@ import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
|
||||
import { Explain, useUiMode } from '@/components/common/UiModeContext'
|
||||
import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
|
||||
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
|
||||
import { tokensApi } from '@/services/api/tokens'
|
||||
import {
|
||||
HOME_DASHBOARD_REFRESH_MS,
|
||||
HOME_PRICE_FEED_REFRESH_MS,
|
||||
resolveHomePriceFeedAddresses,
|
||||
} from '@/utils/featuredTokens'
|
||||
|
||||
type HomeStats = ExplorerStats
|
||||
|
||||
@@ -156,8 +162,30 @@ export default function Home({
|
||||
)
|
||||
}, [chainId])
|
||||
|
||||
const loadFeaturedPrices = useCallback(async () => {
|
||||
const [curatedResult, reportResult] = await Promise.all([
|
||||
tokensApi.listCuratedSafe(chainId),
|
||||
tokensApi.listReportSafe(chainId),
|
||||
])
|
||||
|
||||
const addresses = resolveHomePriceFeedAddresses(
|
||||
curatedResult.ok ? curatedResult.data : [],
|
||||
reportResult.ok ? reportResult.data : [],
|
||||
)
|
||||
|
||||
const { data } = await tokenAggregationApi.getTokensByAddressSafe(chainId, addresses)
|
||||
setFeaturedPrices(data)
|
||||
}, [chainId])
|
||||
|
||||
useEffect(() => {
|
||||
loadDashboard()
|
||||
const timer = window.setInterval(() => {
|
||||
void loadDashboard()
|
||||
}, HOME_DASHBOARD_REFRESH_MS)
|
||||
|
||||
return () => {
|
||||
window.clearInterval(timer)
|
||||
}
|
||||
}, [loadDashboard])
|
||||
|
||||
useEffect(() => {
|
||||
@@ -180,26 +208,33 @@ export default function Home({
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
tokenAggregationApi.getTokensByAddressSafe(138, [
|
||||
'0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
|
||||
'0xf22258f57794CC8E06237084b353Ab30fFfa640b',
|
||||
'0x290e52a8819A4fBd0714e517225429AA2B70EC6B',
|
||||
'0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
|
||||
]).then(({ data }) => {
|
||||
if (!cancelled) {
|
||||
setFeaturedPrices(data)
|
||||
const refreshFeaturedPrices = async () => {
|
||||
try {
|
||||
if (!cancelled) {
|
||||
await loadFeaturedPrices()
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled && process.env.NODE_ENV !== 'production') {
|
||||
console.warn('Failed to load featured token prices:', error)
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
timer = setTimeout(() => {
|
||||
void refreshFeaturedPrices()
|
||||
}, HOME_PRICE_FEED_REFRESH_MS)
|
||||
}
|
||||
}
|
||||
}).catch((error) => {
|
||||
if (!cancelled && process.env.NODE_ENV !== 'production') {
|
||||
console.warn('Failed to load featured token prices:', error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
void refreshFeaturedPrices()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}, [])
|
||||
}, [loadFeaturedPrices])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
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'
|
||||
|
||||
export type WalletChain = {
|
||||
chainId: string
|
||||
@@ -185,8 +187,6 @@ const MAINNET_CWUSDC_TOKEN: TokenListToken = {
|
||||
},
|
||||
}
|
||||
|
||||
const FEATURED_TOKEN_SYMBOLS = ['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cXAUT']
|
||||
|
||||
/** npm-published Snap using open Snap permissions only; stable MetaMask still requires MetaMask’s install allowlist. */
|
||||
const CHAIN138_OPEN_SNAP_ID = 'npm:chain138-open-snap' as const
|
||||
|
||||
@@ -351,6 +351,7 @@ export function AddToMetaMask({
|
||||
)
|
||||
const [metamaskConfig, setMetamaskConfig] = useState<MetaMaskConfig | null>(null)
|
||||
const [metamaskConfigMeta, setMetamaskConfigMeta] = useState<FetchMetadata | null>(null)
|
||||
const [curatedTokens, setCuratedTokens] = useState<TokenListToken[]>([])
|
||||
const [watchAssetProgress, setWatchAssetProgress] = useState<{ current: number; total: number } | null>(null)
|
||||
|
||||
const ethereum = typeof window !== 'undefined'
|
||||
@@ -456,6 +457,24 @@ export function AddToMetaMask({
|
||||
}
|
||||
}, [capabilitiesUrl, metamaskConfigUrl, networksUrl, staticCapabilitiesUrl, tokenListUrl])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
tokensApi.listCuratedSafe(138).then(({ ok, data }) => {
|
||||
if (active) {
|
||||
setCuratedTokens(ok ? (data as TokenListToken[]) : [])
|
||||
}
|
||||
}).catch(() => {
|
||||
if (active) {
|
||||
setCuratedTokens([])
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [])
|
||||
|
||||
const catalogTokens = useMemo(
|
||||
() => (Array.isArray(tokenList?.tokens) ? tokenList.tokens.filter(isTokenListToken) : []),
|
||||
[tokenList],
|
||||
@@ -477,18 +496,10 @@ export function AddToMetaMask({
|
||||
}
|
||||
}, [metamaskConfig, networks])
|
||||
|
||||
const featuredTokens = useMemo(() => {
|
||||
const tokenMap = new Map<string, TokenListToken>()
|
||||
for (const token of catalogTokens) {
|
||||
if (token.chainId !== 138) continue
|
||||
if (!FEATURED_TOKEN_SYMBOLS.includes(token.symbol)) continue
|
||||
tokenMap.set(token.symbol, token)
|
||||
}
|
||||
|
||||
return FEATURED_TOKEN_SYMBOLS
|
||||
.map((symbol) => tokenMap.get(symbol))
|
||||
.filter((token): token is TokenListToken => !!token)
|
||||
}, [catalogTokens])
|
||||
const featuredTokens = useMemo(
|
||||
() => selectWalletFeaturedTokens(catalogTokens, curatedTokens) as TokenListToken[],
|
||||
[catalogTokens, curatedTokens],
|
||||
)
|
||||
|
||||
const watchAssetTokens = useMemo(() => {
|
||||
const endpointTokens = (metamaskConfig?.watchAssets || [])
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import type {
|
||||
CapabilitiesCatalog,
|
||||
FetchMetadata,
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
type AddressTokenTransfer,
|
||||
type TransactionSummary,
|
||||
} from '@/services/api/addresses'
|
||||
import { WALLET_SNAPSHOT_REFRESH_MS } from '@/utils/featuredTokens'
|
||||
import { formatRelativeAge, formatTokenAmount } from '@/utils/format'
|
||||
import {
|
||||
isWatchlistEntry,
|
||||
@@ -110,8 +111,42 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
? isWatchlistEntry(watchlistEntries, walletSession.address)
|
||||
: false
|
||||
|
||||
const loadWalletSnapshot = useCallback(async (address: string) => {
|
||||
const [infoResponse, transactionsResponse, balancesResponse, transfersResponse] = await Promise.all([
|
||||
addressesApi.getSafe(138, address),
|
||||
addressesApi.getTransactionsSafe(138, address, 1, 3),
|
||||
addressesApi.getTokenBalancesSafe(address),
|
||||
addressesApi.getTokenTransfersSafe(address, 1, 4),
|
||||
])
|
||||
|
||||
setAddressInfo(infoResponse.ok ? infoResponse.data : null)
|
||||
setRecentAddressTransactions(transactionsResponse.ok ? transactionsResponse.data : [])
|
||||
setTokenBalances(
|
||||
balancesResponse.ok
|
||||
? [...balancesResponse.data]
|
||||
.filter((balance) => {
|
||||
try {
|
||||
return BigInt(balance.value || '0') > 0n
|
||||
} catch {
|
||||
return Boolean(balance.value)
|
||||
}
|
||||
})
|
||||
.sort((left, right) => {
|
||||
try {
|
||||
return Number(BigInt(right.value || '0') - BigInt(left.value || '0'))
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
.slice(0, 4)
|
||||
: [],
|
||||
)
|
||||
setTokenTransfers(transfersResponse.ok ? transfersResponse.data : [])
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
if (!walletSession?.address) {
|
||||
setAddressInfo(null)
|
||||
@@ -123,50 +158,34 @@ export default function WalletPage(props: WalletPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
Promise.all([
|
||||
addressesApi.getSafe(138, walletSession.address),
|
||||
addressesApi.getTransactionsSafe(138, walletSession.address, 1, 3),
|
||||
addressesApi.getTokenBalancesSafe(walletSession.address),
|
||||
addressesApi.getTokenTransfersSafe(walletSession.address, 1, 4),
|
||||
])
|
||||
.then(([infoResponse, transactionsResponse, balancesResponse, transfersResponse]) => {
|
||||
if (cancelled) return
|
||||
setAddressInfo(infoResponse.ok ? infoResponse.data : null)
|
||||
setRecentAddressTransactions(transactionsResponse.ok ? transactionsResponse.data : [])
|
||||
setTokenBalances(
|
||||
balancesResponse.ok
|
||||
? [...balancesResponse.data]
|
||||
.filter((balance) => {
|
||||
try {
|
||||
return BigInt(balance.value || '0') > 0n
|
||||
} catch {
|
||||
return Boolean(balance.value)
|
||||
}
|
||||
})
|
||||
.sort((left, right) => {
|
||||
try {
|
||||
return Number(BigInt(right.value || '0') - BigInt(left.value || '0'))
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
})
|
||||
.slice(0, 4)
|
||||
: [],
|
||||
)
|
||||
setTokenTransfers(transfersResponse.ok ? transfersResponse.data : [])
|
||||
})
|
||||
.catch(() => {
|
||||
if (cancelled) return
|
||||
setAddressInfo(null)
|
||||
setRecentAddressTransactions([])
|
||||
setTokenBalances([])
|
||||
setTokenTransfers([])
|
||||
})
|
||||
const refreshSnapshot = async () => {
|
||||
try {
|
||||
if (!cancelled) {
|
||||
await loadWalletSnapshot(walletSession.address)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setAddressInfo(null)
|
||||
setRecentAddressTransactions([])
|
||||
setTokenBalances([])
|
||||
setTokenTransfers([])
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
timer = setTimeout(() => {
|
||||
void refreshSnapshot()
|
||||
}, WALLET_SNAPSHOT_REFRESH_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void refreshSnapshot()
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (timer) clearTimeout(timer)
|
||||
}
|
||||
}, [walletSession?.address])
|
||||
}, [loadWalletSnapshot, walletSession?.address])
|
||||
|
||||
return (
|
||||
<main className="container mx-auto px-4 py-6 sm:py-8">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Card, Table, Address } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
normalizeWatchlistAddress,
|
||||
} from '@/utils/watchlist'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import PaginationControls from '@/components/common/PaginationControls'
|
||||
import SectionTabs, { type SectionTab } from '@/components/common/SectionTabs'
|
||||
import GruStandardsCard from '@/components/common/GruStandardsCard'
|
||||
import ContractCodeWorkspace from '@/components/explorer/ContractCodeWorkspace'
|
||||
import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru'
|
||||
@@ -68,6 +70,11 @@ export default function AddressDetailPage() {
|
||||
const [tokenMarkets, setTokenMarkets] = useState<Record<string, TokenAggregationTokenSnapshot>>({})
|
||||
const [nativeAssetPriceUsd, setNativeAssetPriceUsd] = useState<number | undefined>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'contract' | 'balances' | 'transfers' | 'transactions'>('balances')
|
||||
const [balancePage, setBalancePage] = useState(1)
|
||||
const [transferPage, setTransferPage] = useState(1)
|
||||
const [transactionPage, setTransactionPage] = useState(1)
|
||||
const pageSize = 8
|
||||
|
||||
const loadAddressInfo = useCallback(async () => {
|
||||
try {
|
||||
@@ -499,6 +506,34 @@ export default function AddressDetailPage() {
|
||||
).length
|
||||
const nativeAssetSymbol = getNativeAssetDescriptor(chainId).symbol
|
||||
const nativeBalanceUsd = estimateNativeUsdValue(addressInfo?.balance, nativeAssetPriceUsd)
|
||||
const tabs: SectionTab<typeof activeTab>[] = [
|
||||
...(addressInfo?.is_contract ? [{ id: 'contract' as const, label: 'Contract' }] : []),
|
||||
{ id: 'balances', label: 'Balances', count: tokenBalances.length },
|
||||
{ id: 'transfers', label: 'Transfers', count: tokenTransfers.length },
|
||||
{ id: 'transactions', label: 'Transactions', count: transactions.length },
|
||||
]
|
||||
const balancePageCount = Math.max(1, Math.ceil(tokenBalances.length / pageSize))
|
||||
const transferPageCount = Math.max(1, Math.ceil(tokenTransfers.length / pageSize))
|
||||
const transactionPageCount = Math.max(1, Math.ceil(transactions.length / pageSize))
|
||||
const pagedTokenBalances = useMemo(
|
||||
() => tokenBalances.slice((balancePage - 1) * pageSize, balancePage * pageSize),
|
||||
[balancePage, tokenBalances],
|
||||
)
|
||||
const pagedTokenTransfers = useMemo(
|
||||
() => tokenTransfers.slice((transferPage - 1) * pageSize, transferPage * pageSize),
|
||||
[transferPage, tokenTransfers],
|
||||
)
|
||||
const pagedTransactions = useMemo(
|
||||
() => transactions.slice((transactionPage - 1) * pageSize, transactionPage * pageSize),
|
||||
[transactionPage, transactions],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setBalancePage(1)
|
||||
setTransferPage(1)
|
||||
setTransactionPage(1)
|
||||
setActiveTab(addressInfo?.is_contract ? 'contract' : 'balances')
|
||||
}, [address, addressInfo?.is_contract])
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
@@ -633,7 +668,9 @@ export default function AddressDetailPage() {
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{addressInfo.is_contract && (
|
||||
<SectionTabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="mb-6" />
|
||||
|
||||
{activeTab === 'contract' && addressInfo.is_contract && (
|
||||
<Card title="Contract Profile" className="mb-6">
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Interaction Surface">
|
||||
@@ -848,13 +885,13 @@ export default function AddressDetailPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{addressInfo.is_contract && contractProfile ? (
|
||||
{activeTab === 'contract' && addressInfo.is_contract && contractProfile ? (
|
||||
<ContractCodeWorkspace address={addressInfo.address} profile={contractProfile} />
|
||||
) : null}
|
||||
|
||||
{gruProfile ? <div className="mb-6"><GruStandardsCard profile={gruProfile} /></div> : null}
|
||||
{activeTab === 'contract' && gruProfile ? <div className="mb-6"><GruStandardsCard profile={gruProfile} /></div> : null}
|
||||
|
||||
<Card title="Token Balances" className="mb-6">
|
||||
{activeTab === 'balances' ? <Card title="Token Balances" className="mb-6">
|
||||
{gruBalanceCount > 0 ? (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>{gruBalanceCount} visible token balance{gruBalanceCount === 1 ? '' : 's'} look GRU-aware.</span>
|
||||
@@ -865,13 +902,19 @@ export default function AddressDetailPage() {
|
||||
) : null}
|
||||
<Table
|
||||
columns={tokenBalanceColumns}
|
||||
data={tokenBalances}
|
||||
data={pagedTokenBalances}
|
||||
emptyMessage="No token balances were indexed for this address."
|
||||
keyExtractor={(balance) => balance.token_address || `${balance.token_symbol}-${balance.value}`}
|
||||
/>
|
||||
</Card>
|
||||
<PaginationControls
|
||||
page={balancePage}
|
||||
pageCount={balancePageCount}
|
||||
onPageChange={setBalancePage}
|
||||
label="Token balances"
|
||||
/>
|
||||
</Card> : null}
|
||||
|
||||
<Card title="Recent Token Transfers" className="mb-6">
|
||||
{activeTab === 'transfers' ? <Card title="Recent Token Transfers" className="mb-6">
|
||||
{gruTransferCount > 0 ? (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>{gruTransferCount} recent transfer asset{gruTransferCount === 1 ? '' : 's'} carry GRU posture in the explorer.</span>
|
||||
@@ -885,20 +928,32 @@ export default function AddressDetailPage() {
|
||||
) : null}
|
||||
<Table
|
||||
columns={tokenTransferColumns}
|
||||
data={tokenTransfers}
|
||||
data={pagedTokenTransfers}
|
||||
emptyMessage="No token transfers were found for this address."
|
||||
keyExtractor={(transfer) => `${transfer.transaction_hash}-${transfer.token_address}-${transfer.value}`}
|
||||
/>
|
||||
</Card>
|
||||
<PaginationControls
|
||||
page={transferPage}
|
||||
pageCount={transferPageCount}
|
||||
onPageChange={setTransferPage}
|
||||
label="Token transfers"
|
||||
/>
|
||||
</Card> : null}
|
||||
|
||||
<Card title="Transactions">
|
||||
{activeTab === 'transactions' ? <Card title="Transactions">
|
||||
<Table
|
||||
columns={transactionColumns}
|
||||
data={transactions}
|
||||
data={pagedTransactions}
|
||||
emptyMessage="No recent transactions were found for this address."
|
||||
keyExtractor={(tx) => tx.hash}
|
||||
/>
|
||||
</Card>
|
||||
<PaginationControls
|
||||
page={transactionPage}
|
||||
pageCount={transactionPageCount}
|
||||
onPageChange={setTransactionPage}
|
||||
label="Transactions"
|
||||
/>
|
||||
</Card> : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -13,6 +13,8 @@ import EntityBadge from '@/components/common/EntityBadge'
|
||||
import GruStandardsCard from '@/components/common/GruStandardsCard'
|
||||
import TokenSigningSurfaceCard from '@/components/common/TokenSigningSurfaceCard'
|
||||
import MarketEvidenceNote from '@/components/common/MarketEvidenceNote'
|
||||
import PaginationControls from '@/components/common/PaginationControls'
|
||||
import SectionTabs, { type SectionTab } from '@/components/common/SectionTabs'
|
||||
import { formatTokenAmount, formatTimestamp } from '@/utils/format'
|
||||
import { getGruStandardsProfileSafe, type GruStandardsProfile } from '@/services/api/gru'
|
||||
import { getGruExplorerMetadata } from '@/services/api/gruExplorerData'
|
||||
@@ -54,6 +56,11 @@ export default function TokenDetailPage() {
|
||||
const [gruProfile, setGruProfile] = useState<GruStandardsProfile | null>(null)
|
||||
const [contractProfile, setContractProfile] = useState<ContractProfile | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'intelligence' | 'standards' | 'holders' | 'transfers' | 'liquidity'>('intelligence')
|
||||
const [holderPage, setHolderPage] = useState(1)
|
||||
const [transferPage, setTransferPage] = useState(1)
|
||||
const [poolPage, setPoolPage] = useState(1)
|
||||
const pageSize = 8
|
||||
|
||||
const loadToken = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -186,6 +193,35 @@ export default function TokenDetailPage() {
|
||||
() => getGruExplorerMetadata({ address: token?.address || address, symbol: token?.symbol }),
|
||||
[address, token?.address, token?.symbol],
|
||||
)
|
||||
const tabs: SectionTab<typeof activeTab>[] = [
|
||||
{ id: 'intelligence', label: 'Intelligence' },
|
||||
...(gruProfile || gruExplorerMetadata ? [{ id: 'standards' as const, label: 'Standards' }] : []),
|
||||
{ id: 'holders', label: 'Holders', count: holders.length },
|
||||
{ id: 'transfers', label: 'Transfers', count: transfers.length },
|
||||
{ id: 'liquidity', label: 'Liquidity', count: pools.length },
|
||||
]
|
||||
const holderPageCount = Math.max(1, Math.ceil(holders.length / pageSize))
|
||||
const transferPageCount = Math.max(1, Math.ceil(transfers.length / pageSize))
|
||||
const poolPageCount = Math.max(1, Math.ceil(pools.length / pageSize))
|
||||
const pagedHolders = useMemo(
|
||||
() => holders.slice((holderPage - 1) * pageSize, holderPage * pageSize),
|
||||
[holderPage, holders],
|
||||
)
|
||||
const pagedTransfers = useMemo(
|
||||
() => transfers.slice((transferPage - 1) * pageSize, transferPage * pageSize),
|
||||
[transferPage, transfers],
|
||||
)
|
||||
const pagedPools = useMemo(
|
||||
() => pools.slice((poolPage - 1) * pageSize, poolPage * pageSize),
|
||||
[poolPage, pools],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab('intelligence')
|
||||
setHolderPage(1)
|
||||
setTransferPage(1)
|
||||
setPoolPage(1)
|
||||
}, [address])
|
||||
|
||||
const holderColumns = [
|
||||
{
|
||||
@@ -358,7 +394,9 @@ export default function TokenDetailPage() {
|
||||
|
||||
<TokenSigningSurfaceCard address={token.address} contractProfile={contractProfile} />
|
||||
|
||||
<Card title="Token Intelligence">
|
||||
<SectionTabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="mb-6" />
|
||||
|
||||
{activeTab === 'intelligence' ? <Card title="Token Intelligence">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Market Context</div>
|
||||
@@ -403,11 +441,11 @@ export default function TokenDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Card> : null}
|
||||
|
||||
{gruProfile ? <GruStandardsCard profile={gruProfile} /> : null}
|
||||
{activeTab === 'standards' && gruProfile ? <GruStandardsCard profile={gruProfile} /> : null}
|
||||
|
||||
{gruExplorerMetadata ? (
|
||||
{activeTab === 'standards' && gruExplorerMetadata ? (
|
||||
<Card title="x402 And ISO-20022 Posture">
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
|
||||
@@ -450,7 +488,7 @@ export default function TokenDetailPage() {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{gruExplorerMetadata && gruExplorerMetadata.otherNetworks.length > 0 ? (
|
||||
{activeTab === 'standards' && gruExplorerMetadata && gruExplorerMetadata.otherNetworks.length > 0 ? (
|
||||
<Card title="Other Networks">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
@@ -483,17 +521,18 @@ export default function TokenDetailPage() {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card title="Top Holders">
|
||||
{activeTab === 'holders' ? <Card title="Top Holders">
|
||||
<Table
|
||||
layout="tabular"
|
||||
columns={holderColumns}
|
||||
data={holders}
|
||||
data={pagedHolders}
|
||||
emptyMessage="No holder data was available for this token."
|
||||
keyExtractor={(holder) => holder.address}
|
||||
/>
|
||||
</Card>
|
||||
<PaginationControls page={holderPage} pageCount={holderPageCount} onPageChange={setHolderPage} label="Holders" />
|
||||
</Card> : null}
|
||||
|
||||
<Card title="Recent Transfers">
|
||||
{activeTab === 'transfers' ? <Card title="Recent Transfers">
|
||||
{gruExplorerMetadata ? (
|
||||
<div className="mb-4 flex flex-wrap items-center gap-3 text-sm text-gray-600 dark:text-gray-400">
|
||||
<span>
|
||||
@@ -509,20 +548,22 @@ export default function TokenDetailPage() {
|
||||
) : null}
|
||||
<Table
|
||||
columns={transferColumns}
|
||||
data={transfers}
|
||||
data={pagedTransfers}
|
||||
emptyMessage="No recent token transfers were available."
|
||||
keyExtractor={(transfer) => `${transfer.transaction_hash}-${transfer.value}-${transfer.from_address}`}
|
||||
/>
|
||||
</Card>
|
||||
<PaginationControls page={transferPage} pageCount={transferPageCount} onPageChange={setTransferPage} label="Transfers" />
|
||||
</Card> : null}
|
||||
|
||||
<Card title="Related Liquidity">
|
||||
{activeTab === 'liquidity' ? <Card title="Related Liquidity">
|
||||
<Table
|
||||
columns={poolColumns}
|
||||
data={pools}
|
||||
data={pagedPools}
|
||||
emptyMessage="No related liquidity pools were exposed through mission control for this token."
|
||||
keyExtractor={(pool) => pool.address}
|
||||
/>
|
||||
</Card>
|
||||
<PaginationControls page={poolPage} pageCount={poolPageCount} onPageChange={setPoolPage} label="Pools" />
|
||||
</Card> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { tokensApi } from '@/services/api/tokens'
|
||||
import type { TokenListToken } from '@/services/api/config'
|
||||
import { tokenAggregationApi, type TokenAggregationTokenSnapshot } from '@/services/api/tokenAggregation'
|
||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||
import { selectCuratedFeaturedTokens } from '@/utils/featuredTokens'
|
||||
|
||||
const quickSearches = [
|
||||
{ label: 'cUSDT', description: 'Canonical compliant USD treasury / government bond liquidity and address results.' },
|
||||
@@ -84,14 +85,10 @@ export default function TokensPage({ initialCuratedTokens }: TokensPageProps) {
|
||||
}
|
||||
}, [initialCuratedTokens])
|
||||
|
||||
const featuredCuratedTokens = useMemo(() => {
|
||||
const preferred = ['cUSDT', 'cUSDC', 'cXAUC', 'cXAUT', 'cEURT', 'USDT']
|
||||
const selected = preferred
|
||||
.map((symbol) => curatedTokens.find((token) => token.symbol === symbol))
|
||||
.filter((token): token is TokenListToken => Boolean(token?.address))
|
||||
|
||||
return selected.length > 0 ? selected : curatedTokens.slice(0, 6)
|
||||
}, [curatedTokens])
|
||||
const featuredCuratedTokens = useMemo(
|
||||
() => selectCuratedFeaturedTokens(curatedTokens) as TokenListToken[],
|
||||
[curatedTokens],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { Card, Address, Table } from '@/libs/frontend-ui-primitives'
|
||||
import Link from 'next/link'
|
||||
@@ -15,9 +15,15 @@ import { formatTimestamp, formatTokenAmount, formatWeiAsEth } from '@/utils/form
|
||||
import { DetailRow } from '@/components/common/DetailRow'
|
||||
import EntityBadge from '@/components/common/EntityBadge'
|
||||
import PageIntro from '@/components/common/PageIntro'
|
||||
import PaginationControls from '@/components/common/PaginationControls'
|
||||
import SectionTabs, { type SectionTab } from '@/components/common/SectionTabs'
|
||||
import { getGruCatalogPosture } from '@/services/api/gruCatalog'
|
||||
import { assessTransactionCompliance } from '@/utils/transactionCompliance'
|
||||
import { tokenAggregationApi, type TokenAggregationHistoricalPriceSnapshot } from '@/services/api/tokenAggregation'
|
||||
import {
|
||||
tokenAggregationApi,
|
||||
type CheckpointTxAttestationSnapshot,
|
||||
type TokenAggregationHistoricalPriceSnapshot,
|
||||
} from '@/services/api/tokenAggregation'
|
||||
import { estimateNativeUsdValue, estimateTokenUsdValue, getNativeAssetDescriptor, getNativeAssetPriceAtSafe } from '@/services/api/nativeAssetPricing'
|
||||
|
||||
function isValidTransactionHash(value: string) {
|
||||
@@ -69,7 +75,12 @@ export default function TransactionDetailPage() {
|
||||
const [diagnostic, setDiagnostic] = useState<TransactionLookupDiagnostic | null>(null)
|
||||
const [historicalTokenPrices, setHistoricalTokenPrices] = useState<Record<string, TokenAggregationHistoricalPriceSnapshot>>({})
|
||||
const [historicalNativePrice, setHistoricalNativePrice] = useState<TokenAggregationHistoricalPriceSnapshot | null>(null)
|
||||
const [checkpointAttestation, setCheckpointAttestation] = useState<CheckpointTxAttestationSnapshot | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [activeTab, setActiveTab] = useState<'evidence' | 'details' | 'transfers' | 'internal' | 'raw'>('evidence')
|
||||
const [transferPage, setTransferPage] = useState(1)
|
||||
const [internalPage, setInternalPage] = useState(1)
|
||||
const pageSize = 8
|
||||
|
||||
const loadTransaction = useCallback(async () => {
|
||||
setLoading(true)
|
||||
@@ -185,6 +196,25 @@ export default function TransactionDetailPage() {
|
||||
}
|
||||
}, [chainId, transaction?.created_at])
|
||||
|
||||
useEffect(() => {
|
||||
let active = true
|
||||
if (!isValidHash) {
|
||||
setCheckpointAttestation(null)
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}
|
||||
tokenAggregationApi.getCheckpointAttestationSafe(hash).then(({ ok, data }) => {
|
||||
if (!active) return
|
||||
setCheckpointAttestation(ok && data?.included ? data : null)
|
||||
}).catch(() => {
|
||||
if (active) setCheckpointAttestation(null)
|
||||
})
|
||||
return () => {
|
||||
active = false
|
||||
}
|
||||
}, [hash, isValidHash])
|
||||
|
||||
const tokenTransferColumns = [
|
||||
{
|
||||
header: 'Token',
|
||||
@@ -234,16 +264,24 @@ export default function TransactionDetailPage() {
|
||||
{
|
||||
header: 'Transfer-Time Value',
|
||||
accessor: (transfer: TransactionTokenTransfer) => {
|
||||
const want = transfer.token_address.toLowerCase()
|
||||
const checkpointLine = checkpointAttestation?.leaf?.transfers?.find((line) => {
|
||||
const addr = (line.tokenAddress || line.token || '').toLowerCase()
|
||||
return addr === want
|
||||
})
|
||||
const historicalPrice = historicalTokenPrices[transfer.token_address.toLowerCase()]
|
||||
const totalUsd = estimateTokenUsdValue(transfer.amount, transfer.token_decimals, historicalPrice?.priceUsd)
|
||||
const totalUsd = checkpointLine?.valueUsd != null
|
||||
? Number(checkpointLine.valueUsd)
|
||||
: estimateTokenUsdValue(transfer.amount, transfer.token_decimals, historicalPrice?.priceUsd)
|
||||
const priceSource = checkpointLine?.priceSource ?? historicalPrice?.source
|
||||
return (
|
||||
<div className="space-y-1 text-sm">
|
||||
<div>{totalUsd != null ? formatUsd(totalUsd) : 'Unavailable'}</div>
|
||||
<div>{totalUsd != null && Number.isFinite(totalUsd) ? formatUsd(totalUsd) : 'Unavailable'}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Unit price: {formatUsd(historicalPrice?.priceUsd)}
|
||||
Unit price: {checkpointLine?.valueUsd != null ? 'checkpoint leaf' : formatUsd(historicalPrice?.priceUsd)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Source: {formatHistoricalPriceSource(historicalPrice?.source)}
|
||||
Source: {checkpointLine ? `checkpoint (${priceSource || 'enriched'})` : formatHistoricalPriceSource(priceSource)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -299,8 +337,14 @@ export default function TransactionDetailPage() {
|
||||
const tokenTransferCount = transaction?.token_transfers?.length || 0
|
||||
const internalCallCount = internalCalls.length
|
||||
const nativeAssetSymbol = getNativeAssetDescriptor(chainId).symbol
|
||||
const nativeValueUsd = estimateNativeUsdValue(transaction?.value, historicalNativePrice?.priceUsd)
|
||||
const checkpointLeaf = checkpointAttestation?.leaf
|
||||
const checkpointNativeUsd = checkpointLeaf?.nativeValueUsd ?? checkpointLeaf?.valueUsd
|
||||
const parsedCheckpointNativeUsd = checkpointNativeUsd != null ? Number(checkpointNativeUsd) : null
|
||||
const nativeValueUsd = parsedCheckpointNativeUsd != null && Number.isFinite(parsedCheckpointNativeUsd)
|
||||
? parsedCheckpointNativeUsd
|
||||
: estimateNativeUsdValue(transaction?.value, historicalNativePrice?.priceUsd)
|
||||
const nativeFeeUsd = estimateNativeUsdValue(transaction?.fee, historicalNativePrice?.priceUsd)
|
||||
const checkpointTotalUsd = checkpointLeaf?.totalTransfersUsd ?? checkpointLeaf?.valueUsd
|
||||
const complianceAssessment = transaction
|
||||
? assessTransactionCompliance({
|
||||
transaction,
|
||||
@@ -308,6 +352,29 @@ export default function TransactionDetailPage() {
|
||||
tokenTransfers: transaction.token_transfers || [],
|
||||
})
|
||||
: null
|
||||
const tabs: SectionTab<typeof activeTab>[] = [
|
||||
{ id: 'evidence', label: 'Evidence' },
|
||||
{ id: 'details', label: 'Details' },
|
||||
{ id: 'transfers', label: 'Transfers', count: tokenTransferCount },
|
||||
{ id: 'internal', label: 'Internal', count: internalCallCount },
|
||||
...(transaction?.input_data ? [{ id: 'raw' as const, label: 'Raw input' }] : []),
|
||||
]
|
||||
const transferPageCount = Math.max(1, Math.ceil((transaction?.token_transfers?.length || 0) / pageSize))
|
||||
const internalPageCount = Math.max(1, Math.ceil(internalCalls.length / pageSize))
|
||||
const pagedTokenTransfers = useMemo(
|
||||
() => (transaction?.token_transfers || []).slice((transferPage - 1) * pageSize, transferPage * pageSize),
|
||||
[transaction?.token_transfers, transferPage],
|
||||
)
|
||||
const pagedInternalCalls = useMemo(
|
||||
() => internalCalls.slice((internalPage - 1) * pageSize, internalPage * pageSize),
|
||||
[internalCalls, internalPage],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
setActiveTab('evidence')
|
||||
setTransferPage(1)
|
||||
setInternalPage(1)
|
||||
}, [hash])
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||
@@ -406,7 +473,13 @@ export default function TransactionDetailPage() {
|
||||
{nativeValueUsd != null ? ` (${formatUsd(nativeValueUsd)})` : ''}
|
||||
</div>
|
||||
<div>Transfer-time native price: {historicalNativePrice?.priceUsd != null ? `${formatUsd(historicalNativePrice.priceUsd)} per ${nativeAssetSymbol}` : 'Unavailable'}</div>
|
||||
<div>Pricing source: {formatHistoricalPriceSource(historicalNativePrice?.source)}</div>
|
||||
<div>Pricing source: {checkpointLeaf ? `checkpoint mirror (${checkpointLeaf.priceSource || 'enriched'})` : formatHistoricalPriceSource(historicalNativePrice?.source)}</div>
|
||||
{checkpointAttestation ? (
|
||||
<div>
|
||||
Checkpoint batch #{checkpointAttestation.batchId}
|
||||
{checkpointTotalUsd != null ? ` — total ${formatUsd(Number(checkpointTotalUsd))}` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
<div>Token transfers: {tokenTransferCount.toLocaleString()}</div>
|
||||
<div>Internal calls: {internalCallCount.toLocaleString()}</div>
|
||||
</div>
|
||||
@@ -430,7 +503,9 @@ export default function TransactionDetailPage() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{complianceAssessment ? (
|
||||
<SectionTabs tabs={tabs} activeTab={activeTab} onChange={setActiveTab} className="mb-6" />
|
||||
|
||||
{activeTab === 'evidence' && complianceAssessment ? (
|
||||
<Card title="Transaction Evidence Matrix">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
@@ -458,7 +533,7 @@ export default function TransactionDetailPage() {
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card title="Transaction Information">
|
||||
{activeTab === 'details' ? <Card title="Transaction Information">
|
||||
<dl className="space-y-4">
|
||||
<DetailRow label="Hash">
|
||||
<Address address={transaction.hash} />
|
||||
@@ -522,9 +597,9 @@ export default function TransactionDetailPage() {
|
||||
</DetailRow>
|
||||
)}
|
||||
</dl>
|
||||
</Card>
|
||||
</Card> : null}
|
||||
|
||||
{transaction.decoded_input && transaction.decoded_input.parameters.length > 0 && (
|
||||
{activeTab === 'details' && transaction.decoded_input && transaction.decoded_input.parameters.length > 0 && (
|
||||
<Card title="Decoded Input">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
@@ -553,25 +628,27 @@ export default function TransactionDetailPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card title="Token Transfers">
|
||||
{activeTab === 'transfers' ? <Card title="Token Transfers">
|
||||
<Table
|
||||
columns={tokenTransferColumns}
|
||||
data={transaction.token_transfers || []}
|
||||
data={pagedTokenTransfers}
|
||||
emptyMessage="No token transfers were indexed for this transaction."
|
||||
keyExtractor={(transfer) => `${transfer.token_address}-${transfer.from_address}-${transfer.to_address}-${transfer.amount}`}
|
||||
/>
|
||||
</Card>
|
||||
<PaginationControls page={transferPage} pageCount={transferPageCount} onPageChange={setTransferPage} label="Token transfers" />
|
||||
</Card> : null}
|
||||
|
||||
<Card title="Internal Transactions">
|
||||
{activeTab === 'internal' ? <Card title="Internal Transactions">
|
||||
<Table
|
||||
columns={internalCallColumns}
|
||||
data={internalCalls}
|
||||
data={pagedInternalCalls}
|
||||
emptyMessage="No internal transactions were exposed for this transaction."
|
||||
keyExtractor={(call) => `${call.from_address}-${call.to_address || call.contract_address || 'unknown'}-${call.value}-${call.type || 'call'}`}
|
||||
/>
|
||||
</Card>
|
||||
<PaginationControls page={internalPage} pageCount={internalPageCount} onPageChange={setInternalPage} label="Internal calls" />
|
||||
</Card> : null}
|
||||
|
||||
{transaction.input_data && (
|
||||
{activeTab === 'raw' && transaction.input_data && (
|
||||
<Card title="Raw Input Data">
|
||||
<pre className="overflow-x-auto whitespace-pre-wrap break-all rounded-lg bg-gray-50 p-4 text-xs text-gray-800 dark:bg-gray-950 dark:text-gray-200">
|
||||
{transaction.input_data}
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function WalletRoutePage(props: WalletRoutePageProps) {
|
||||
export const getServerSideProps: GetServerSideProps<WalletRoutePageProps> = async () => {
|
||||
const [networksResult, tokenListResult, capabilitiesResult] = await Promise.all([
|
||||
fetchPublicJsonWithMeta<NetworksCatalog>('/api/config/networks').catch(() => null),
|
||||
fetchPublicJsonWithMeta<TokenListCatalog>('/api/config/token-list').catch(() => null),
|
||||
fetchPublicJsonWithMeta<TokenListCatalog>('/api/v1/report/token-list?chainId=138').catch(() => null),
|
||||
fetchPublicJsonWithMeta<CapabilitiesCatalog>('/api/config/capabilities').catch(() => null),
|
||||
])
|
||||
|
||||
|
||||
@@ -98,7 +98,8 @@ interface ABIEntry {
|
||||
}
|
||||
|
||||
async function fetchCompatJson<T>(params: URLSearchParams): Promise<T> {
|
||||
const response = await fetch(`${getExplorerApiBase()}/api?${params.toString()}`)
|
||||
const base = typeof window !== 'undefined' ? '' : getExplorerApiBase()
|
||||
const response = await fetch(`${base}/api/?${params.toString()}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`)
|
||||
}
|
||||
|
||||
@@ -26,6 +26,33 @@ export interface TokenAggregationHistoricalPriceSnapshot {
|
||||
source?: string
|
||||
}
|
||||
|
||||
export interface CheckpointTransferUsdLine {
|
||||
tokenAddress?: string
|
||||
token?: string
|
||||
tokenSymbol?: string
|
||||
symbol?: string
|
||||
amountRaw?: string
|
||||
decimals?: number
|
||||
valueUsd?: string
|
||||
priceSource?: string
|
||||
}
|
||||
|
||||
export interface CheckpointTxAttestationSnapshot {
|
||||
txHash: string
|
||||
included: boolean
|
||||
batchId: string
|
||||
batchTotalUsd?: string
|
||||
leaf: {
|
||||
valueUsd?: string
|
||||
nativeValueUsd?: string
|
||||
tokenValueUsd?: string
|
||||
totalTransfersUsd?: string
|
||||
priceSource?: string
|
||||
transfers?: CheckpointTransferUsdLine[]
|
||||
} | null
|
||||
source?: string
|
||||
}
|
||||
|
||||
interface RawTokenAggregationTokenResponse {
|
||||
token?: {
|
||||
chainId?: number | string | null
|
||||
@@ -190,6 +217,23 @@ export const tokenAggregationApi = {
|
||||
return { ok: data.length > 0, data }
|
||||
},
|
||||
|
||||
getCheckpointAttestationSafe: async (
|
||||
txHash: string,
|
||||
): Promise<{ ok: boolean; data: CheckpointTxAttestationSnapshot | null }> => {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${getTokenAggregationBase()}/checkpoint/tx/${txHash}/attestation`,
|
||||
)
|
||||
if (!response.ok) {
|
||||
return { ok: false, data: null }
|
||||
}
|
||||
const raw = (await response.json()) as CheckpointTxAttestationSnapshot
|
||||
return { ok: true, data: raw }
|
||||
} catch {
|
||||
return { ok: false, data: null }
|
||||
}
|
||||
},
|
||||
|
||||
getPriceAtSafe: async (
|
||||
chainId: number,
|
||||
address: string,
|
||||
|
||||
@@ -263,7 +263,21 @@ export const tokensApi = {
|
||||
const response = await configApi.getTokenList()
|
||||
const data = (response.tokens || [])
|
||||
.filter((token) => token.chainId === chainId && typeof token.address === 'string' && token.address.trim().length > 0)
|
||||
.sort((left, right) => (left.symbol || left.name || '').localeCompare(right.symbol || right.name || ''))
|
||||
.sort((left, right) => (left.symbol || left.name || '').localeCompare(right.symbol || left.name || ''))
|
||||
return { ok: true, data }
|
||||
} catch {
|
||||
return { ok: false, data: [] }
|
||||
}
|
||||
},
|
||||
|
||||
listReportSafe: async (chainId = 138): Promise<{ ok: boolean; data: TokenListToken[] }> => {
|
||||
try {
|
||||
const response = await fetchBlockscoutJson<{ tokens?: TokenListToken[] }>(
|
||||
`/api/v1/report/token-list?chainId=${chainId}`,
|
||||
)
|
||||
const data = (response.tokens || [])
|
||||
.filter((token) => token.chainId === chainId && typeof token.address === 'string' && token.address.trim().length > 0)
|
||||
.sort((left, right) => (left.symbol || left.name || '').localeCompare(right.symbol || left.name || ''))
|
||||
return { ok: true, data }
|
||||
} catch {
|
||||
return { ok: false, data: [] }
|
||||
|
||||
68
frontend/src/utils/featuredTokens.test.ts
Normal file
68
frontend/src/utils/featuredTokens.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
pickBestTokenForSymbol,
|
||||
resolveHomePriceFeedAddresses,
|
||||
selectWalletFeaturedTokens,
|
||||
} from './featuredTokens'
|
||||
|
||||
describe('featuredTokens', () => {
|
||||
it('prefers canonical v1 cUSDC over staged v2 when both share a symbol', () => {
|
||||
const tokens = [
|
||||
{
|
||||
chainId: 138,
|
||||
symbol: 'cUSDC',
|
||||
address: '0xf22258f57794CC8E06237084b353Ab30fFfa640b',
|
||||
decimals: 6,
|
||||
},
|
||||
{
|
||||
chainId: 138,
|
||||
symbol: 'cUSDC',
|
||||
address: '0x219522c60e83dEe01FC5b0329d6fA8fD84b9D13d',
|
||||
decimals: 6,
|
||||
extensions: { deploymentVersion: 'v2', deploymentStatus: 'staged' },
|
||||
},
|
||||
]
|
||||
|
||||
expect(pickBestTokenForSymbol(tokens, 'cUSDC')?.address).toBe('0xf22258f57794CC8E06237084b353Ab30fFfa640b')
|
||||
})
|
||||
|
||||
it('selects wallet featured tokens using curated address preference', () => {
|
||||
const curated = [
|
||||
{
|
||||
chainId: 138,
|
||||
symbol: 'cUSDT',
|
||||
address: '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
|
||||
decimals: 6,
|
||||
},
|
||||
]
|
||||
const catalog = [
|
||||
...curated,
|
||||
{
|
||||
chainId: 138,
|
||||
symbol: 'cUSDT',
|
||||
address: '0x9FBfab33882Efe0038DAa608185718b772EE5660',
|
||||
decimals: 6,
|
||||
extensions: { deploymentVersion: 'v2', deploymentStatus: 'staged' },
|
||||
},
|
||||
{
|
||||
chainId: 138,
|
||||
symbol: 'USDT',
|
||||
address: '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1',
|
||||
decimals: 6,
|
||||
},
|
||||
]
|
||||
|
||||
const featured = selectWalletFeaturedTokens(catalog, curated)
|
||||
expect(featured[0]?.address).toBe('0x93E66202A11B1772E55407B32B44e5Cd8eda7f22')
|
||||
expect(featured.some((token) => token.symbol === 'USDT')).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to hardcoded home price feed addresses when catalogs are empty', () => {
|
||||
expect(resolveHomePriceFeedAddresses([], [])).toEqual([
|
||||
'0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
|
||||
'0xf22258f57794CC8E06237084b353Ab30fFfa640b',
|
||||
'0x290E52a8819A4fBd0714E517225429AA2B70EC6b',
|
||||
'0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
|
||||
])
|
||||
})
|
||||
})
|
||||
162
frontend/src/utils/featuredTokens.ts
Normal file
162
frontend/src/utils/featuredTokens.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
export const CHAIN138_ID = 138
|
||||
|
||||
export const CURATED_FEATURED_SYMBOLS = ['cUSDT', 'cUSDC', 'cXAUC', 'cXAUT', 'cEURT', 'USDT'] as const
|
||||
export const WALLET_FEATURED_SYMBOLS = ['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cXAUT'] as const
|
||||
export const HOME_EXTRA_PRICE_SYMBOLS = ['WETH'] as const
|
||||
|
||||
export const FALLBACK_HOME_PRICE_FEED_ADDRESSES = [
|
||||
'0x93E66202A11B1772E55407B32B44e5Cd8eda7f22',
|
||||
'0xf22258f57794CC8E06237084b353Ab30fFfa640b',
|
||||
'0x290E52a8819A4fBd0714E517225429AA2B70EC6b',
|
||||
'0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2',
|
||||
] as const
|
||||
|
||||
export const HOME_DASHBOARD_REFRESH_MS = 60_000
|
||||
export const HOME_PRICE_FEED_REFRESH_MS = 60_000
|
||||
export const WALLET_SNAPSHOT_REFRESH_MS = 60_000
|
||||
|
||||
export interface FeaturedTokenCandidate {
|
||||
chainId?: number
|
||||
symbol?: string
|
||||
address?: string
|
||||
name?: string
|
||||
decimals?: number
|
||||
logoURI?: string
|
||||
tags?: string[]
|
||||
extensions?: Record<string, unknown>
|
||||
}
|
||||
|
||||
function normalizeSymbol(symbol?: string): string {
|
||||
return (symbol || '').trim()
|
||||
}
|
||||
|
||||
function normalizeAddressKey(address?: string): string {
|
||||
return (address || '').trim().toLowerCase()
|
||||
}
|
||||
|
||||
function isValidAddress(address?: string): address is string {
|
||||
return typeof address === 'string' && /^0x[a-fA-F0-9]{40}$/.test(address.trim())
|
||||
}
|
||||
|
||||
export function isStagedFeaturedToken(token: FeaturedTokenCandidate): boolean {
|
||||
const extensions = token.extensions || {}
|
||||
const deploymentVersion = String(extensions.deploymentVersion || '').toLowerCase()
|
||||
const deploymentStatus = String(extensions.deploymentStatus || '').toLowerCase()
|
||||
return deploymentVersion === 'v2' || deploymentStatus === 'staged'
|
||||
}
|
||||
|
||||
export function featuredTokenRank(
|
||||
token: FeaturedTokenCandidate,
|
||||
curatedAddressKeys?: ReadonlySet<string>,
|
||||
): number {
|
||||
let rank = 0
|
||||
if (isStagedFeaturedToken(token)) rank += 100
|
||||
const addressKey = normalizeAddressKey(token.address)
|
||||
if (curatedAddressKeys && addressKey && !curatedAddressKeys.has(addressKey)) {
|
||||
rank += 10
|
||||
}
|
||||
return rank
|
||||
}
|
||||
|
||||
export function pickBestTokenForSymbol(
|
||||
tokens: FeaturedTokenCandidate[],
|
||||
symbol: string,
|
||||
options?: { chainId?: number; curatedAddressKeys?: ReadonlySet<string> },
|
||||
): FeaturedTokenCandidate | null {
|
||||
const chainId = options?.chainId ?? CHAIN138_ID
|
||||
const normalizedSymbol = normalizeSymbol(symbol)
|
||||
const matches = tokens.filter(
|
||||
(token) =>
|
||||
token.chainId === chainId &&
|
||||
normalizeSymbol(token.symbol) === normalizedSymbol &&
|
||||
isValidAddress(token.address),
|
||||
)
|
||||
|
||||
if (matches.length === 0) return null
|
||||
|
||||
return [...matches].sort(
|
||||
(left, right) =>
|
||||
featuredTokenRank(left, options?.curatedAddressKeys) -
|
||||
featuredTokenRank(right, options?.curatedAddressKeys),
|
||||
)[0]
|
||||
}
|
||||
|
||||
export function selectFeaturedTokensBySymbol(
|
||||
tokens: FeaturedTokenCandidate[],
|
||||
preferredSymbols: readonly string[],
|
||||
options?: { chainId?: number; limit?: number; curatedAddressKeys?: ReadonlySet<string> },
|
||||
): FeaturedTokenCandidate[] {
|
||||
const selected = preferredSymbols
|
||||
.map((symbol) => pickBestTokenForSymbol(tokens, symbol, options))
|
||||
.filter((token): token is FeaturedTokenCandidate & { address: string } => Boolean(token?.address))
|
||||
|
||||
const deduped: FeaturedTokenCandidate[] = []
|
||||
const seen = new Set<string>()
|
||||
for (const token of selected) {
|
||||
const key = normalizeAddressKey(token.address)
|
||||
if (seen.has(key)) continue
|
||||
seen.add(key)
|
||||
deduped.push(token)
|
||||
}
|
||||
|
||||
if (deduped.length > 0) {
|
||||
return options?.limit ? deduped.slice(0, options.limit) : deduped
|
||||
}
|
||||
|
||||
const fallback = tokens
|
||||
.filter((token) => (options?.chainId ?? CHAIN138_ID) === token.chainId && isValidAddress(token.address))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
featuredTokenRank(left, options?.curatedAddressKeys) -
|
||||
featuredTokenRank(right, options?.curatedAddressKeys),
|
||||
)
|
||||
|
||||
return options?.limit ? fallback.slice(0, options.limit) : fallback.slice(0, 6)
|
||||
}
|
||||
|
||||
export function buildCuratedAddressKeys(tokens: FeaturedTokenCandidate[]): Set<string> {
|
||||
return new Set(
|
||||
tokens
|
||||
.filter((token) => token.chainId === CHAIN138_ID && isValidAddress(token.address))
|
||||
.map((token) => normalizeAddressKey(token.address)),
|
||||
)
|
||||
}
|
||||
|
||||
export function selectCuratedFeaturedTokens(curatedTokens: FeaturedTokenCandidate[]): FeaturedTokenCandidate[] {
|
||||
return selectFeaturedTokensBySymbol(curatedTokens, CURATED_FEATURED_SYMBOLS, { chainId: CHAIN138_ID })
|
||||
}
|
||||
|
||||
export function selectWalletFeaturedTokens(
|
||||
catalogTokens: FeaturedTokenCandidate[],
|
||||
curatedTokens: FeaturedTokenCandidate[] = [],
|
||||
): FeaturedTokenCandidate[] {
|
||||
const curatedAddressKeys = buildCuratedAddressKeys(curatedTokens)
|
||||
return selectFeaturedTokensBySymbol(catalogTokens, WALLET_FEATURED_SYMBOLS, {
|
||||
chainId: CHAIN138_ID,
|
||||
curatedAddressKeys: curatedAddressKeys.size > 0 ? curatedAddressKeys : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
export function selectHomePriceFeedTokens(
|
||||
curatedTokens: FeaturedTokenCandidate[],
|
||||
reportTokens: FeaturedTokenCandidate[] = [],
|
||||
): FeaturedTokenCandidate[] {
|
||||
const curatedAddressKeys = buildCuratedAddressKeys(curatedTokens)
|
||||
const pool = reportTokens.length > 0 ? reportTokens : curatedTokens
|
||||
return selectFeaturedTokensBySymbol(pool, [...CURATED_FEATURED_SYMBOLS, ...HOME_EXTRA_PRICE_SYMBOLS], {
|
||||
chainId: CHAIN138_ID,
|
||||
curatedAddressKeys: curatedAddressKeys.size > 0 ? curatedAddressKeys : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
export function resolveHomePriceFeedAddresses(
|
||||
curatedTokens: FeaturedTokenCandidate[],
|
||||
reportTokens: FeaturedTokenCandidate[] = [],
|
||||
): string[] {
|
||||
const fromCatalog = selectHomePriceFeedTokens(curatedTokens, reportTokens)
|
||||
.map((token) => token.address)
|
||||
.filter(isValidAddress)
|
||||
|
||||
if (fromCatalog.length > 0) return fromCatalog
|
||||
return [...FALLBACK_HOME_PRICE_FEED_ADDRESSES]
|
||||
}
|
||||
@@ -201,6 +201,31 @@ for url in external_roots:
|
||||
except Exception as exc:
|
||||
print(f"ERR {url} [{exc}]")
|
||||
|
||||
print("\n== Native ETH USD (token-aggregation WETH proxy) ==")
|
||||
weth = "0xc02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2"
|
||||
try:
|
||||
cg = session.get(
|
||||
"https://api.coingecko.com/api/v3/simple/price",
|
||||
params={"ids": "ethereum", "vs_currencies": "usd"},
|
||||
timeout=15,
|
||||
).json()
|
||||
cg_usd = float(cg["ethereum"]["usd"])
|
||||
ta = session.get(
|
||||
f"{base}/token-aggregation/api/v1/tokens/{weth}",
|
||||
params={"chainId": 138},
|
||||
timeout=20,
|
||||
).json()
|
||||
ta_usd = float(ta["token"]["market"]["priceUsd"])
|
||||
delta_pct = abs(ta_usd - cg_usd) / cg_usd * 100.0
|
||||
layer = ta.get("token", {}).get("pricing", {}).get("sourceLayer", "")
|
||||
print(f" coingecko=${cg_usd:.2f} explorer=${ta_usd:.2f} delta={delta_pct:.2f}% layer={layer}")
|
||||
if abs(ta_usd - 2490.0) < 0.01:
|
||||
mark_failure(" ETH price still at stale repo snapshot $2490")
|
||||
if delta_pct > 5.0:
|
||||
mark_failure(f" ETH price drift {delta_pct:.2f}% exceeds 5% vs CoinGecko")
|
||||
except Exception as exc:
|
||||
mark_failure(f" native ETH price check failed: {exc}")
|
||||
|
||||
if failed:
|
||||
sys.exit(1)
|
||||
PY
|
||||
|
||||
Reference in New Issue
Block a user