6132 lines
409 KiB
JavaScript
6132 lines
409 KiB
JavaScript
const API_BASE = '/api';
|
|
const EXPLORER_API_BASE = '/explorer-api';
|
|
const EXPLORER_API_V1_BASE = EXPLORER_API_BASE + '/v1';
|
|
const TOKEN_AGGREGATION_API_BASE = '/token-aggregation/api';
|
|
const EXPLORER_AI_API_BASE = EXPLORER_API_V1_BASE + '/ai';
|
|
const FETCH_TIMEOUT_MS = 15000;
|
|
const RPC_HEALTH_TIMEOUT_MS = 5000;
|
|
const FETCH_MAX_RETRIES = 3;
|
|
const RETRY_DELAY_MS = 1000;
|
|
window.DEBUG_EXPLORER = false;
|
|
let _apiUnavailableBannerShown = false;
|
|
function showAPIUnavailableBanner(status) {
|
|
if (_apiUnavailableBannerShown) return;
|
|
_apiUnavailableBannerShown = true;
|
|
var main = document.getElementById('mainContent');
|
|
if (!main) return;
|
|
var banner = document.createElement('div');
|
|
banner.id = 'apiUnavailableBanner';
|
|
banner.setAttribute('role', 'alert');
|
|
banner.style.cssText = 'background: rgba(200,80,80,0.95); color: #fff; padding: 0.75rem 1rem; margin-bottom: 1rem; border-radius: 8px; font-size: 0.9rem;';
|
|
banner.innerHTML = '<strong>Explorer API temporarily unavailable</strong> (HTTP ' + status + '). Stats, blocks, and transactions cannot load until the backend is running. <a href="https://gitea.d-bis.org/d-bis/explorer-monorepo/src/branch/main/docs/EXPLORER_API_ACCESS.md" target="_blank" rel="noopener noreferrer" style="color: #fff; text-decoration: underline;">See docs</a>.';
|
|
main.insertBefore(banner, main.firstChild);
|
|
}
|
|
(function() {
|
|
var _log = console.log, _warn = console.warn;
|
|
console.log = function() { if (window.DEBUG_EXPLORER) _log.apply(console, arguments); };
|
|
console.warn = function() { if (window.DEBUG_EXPLORER) _warn.apply(console, arguments); };
|
|
})();
|
|
// RPC/WebSocket: VMID 2201 (public RPC). FQDN when HTTPS (avoids mixed content); IP when HTTP (e.g. http://192.168.11.140)
|
|
const RPC_IP = 'http://192.168.11.221:8545'; // Chain 138 - VMID 2201 besu-rpc-public-1
|
|
const RPC_WS_IP = 'ws://192.168.11.221:8546';
|
|
const RPC_FQDN = 'https://rpc-http-pub.d-bis.org'; // VMID 2201 - HTTPS
|
|
const RPC_WS_FQDN = 'wss://rpc-ws-pub.d-bis.org';
|
|
const RPC_URLS = (typeof window !== 'undefined' && window.location && window.location.protocol === 'https:')
|
|
? [RPC_FQDN] : [RPC_IP];
|
|
const RPC_URL = (typeof window !== 'undefined' && window.location && window.location.protocol === 'https:') ? RPC_FQDN : RPC_IP;
|
|
const RPC_WS_URL = (typeof window !== 'undefined' && window.location && window.location.protocol === 'https:') ? RPC_WS_FQDN : RPC_WS_IP;
|
|
let _rpcUrlIndex = 0;
|
|
let _blocksScrollAnimationId = null;
|
|
let _explorerAIState = {
|
|
open: false,
|
|
loading: false,
|
|
messages: [
|
|
{
|
|
role: 'assistant',
|
|
content: 'Explorer AI is ready for read-only ecosystem analysis. Ask about routes, liquidity, bridges, addresses, transactions, or current Chain 138 status.'
|
|
}
|
|
]
|
|
};
|
|
async function getRpcUrl() {
|
|
if (RPC_URLS.length <= 1) return RPC_URLS[0];
|
|
const ac = new AbortController();
|
|
const t = setTimeout(() => ac.abort(), RPC_HEALTH_TIMEOUT_MS);
|
|
for (let i = 0; i < RPC_URLS.length; i++) {
|
|
const url = RPC_URLS[(_rpcUrlIndex + i) % RPC_URLS.length];
|
|
try {
|
|
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', method: 'eth_blockNumber', params: [], id: 1 }), signal: ac.signal });
|
|
clearTimeout(t);
|
|
if (r.ok) { _rpcUrlIndex = (_rpcUrlIndex + i) % RPC_URLS.length; return url; }
|
|
} catch (e) {}
|
|
}
|
|
clearTimeout(t);
|
|
return RPC_URLS[_rpcUrlIndex % RPC_URLS.length];
|
|
}
|
|
const CHAIN_ID = 138; // Hyperledger Besu ChainID 138
|
|
async function rpcCall(method, params) {
|
|
const url = await getRpcUrl();
|
|
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', method, params: params || [], id: 1 }) });
|
|
const j = await r.json();
|
|
if (j.error) throw new Error(j.error.message || 'RPC error');
|
|
return j.result;
|
|
}
|
|
const BLOCKSCOUT_API_ORIGIN = 'https://explorer.d-bis.org/api'; // fallback when not on explorer host
|
|
// Use relative /api when on explorer host so API always hits same host (avoids CORS/origin mismatch with www, port, or proxy)
|
|
const EXPLORER_HOSTS = ['explorer.d-bis.org', '192.168.11.140'];
|
|
const isOnExplorerHost = (typeof window !== 'undefined' && window.location && window.location.hostname && EXPLORER_HOSTS.indexOf(window.location.hostname) !== -1);
|
|
const BLOCKSCOUT_API = isOnExplorerHost ? '/api' : BLOCKSCOUT_API_ORIGIN;
|
|
const EXPLORER_ORIGINS = ['https://explorer.d-bis.org', 'http://explorer.d-bis.org', 'http://192.168.11.140', 'https://192.168.11.140'];
|
|
const EXPLORER_ORIGIN = (typeof window !== 'undefined' && window.location && EXPLORER_ORIGINS.includes(window.location.origin)) ? window.location.origin : 'https://explorer.d-bis.org';
|
|
var I18N = {
|
|
en: { home: 'Home', blocks: 'Blocks', transactions: 'Transactions', addresses: 'Addresses', bridge: 'Bridge', weth: 'WETH', tokens: 'Tokens', pools: 'Pools', more: 'More', analytics: 'Analytics', operator: 'Operator', watchlist: 'Watchlist', searchPlaceholder: 'Address, tx hash, block number, or token/contract name...', connectWallet: 'Connect Wallet', darkMode: 'Dark mode', lightMode: 'Light mode', back: 'Back', exportCsv: 'Export CSV', tokenBalances: 'Token Balances', internalTxns: 'Internal Txns', readContract: 'Read contract', writeContract: 'Write contract', addToWatchlist: 'Add to watchlist', removeFromWatchlist: 'Remove from watchlist', checkApprovals: 'Check token approvals', copied: 'Copied' },
|
|
de: { home: 'Start', blocks: 'Blöcke', transactions: 'Transaktionen', addresses: 'Adressen', bridge: 'Brücke', weth: 'WETH', tokens: 'Token', pools: 'Pools', more: 'Mehr', analytics: 'Analysen', operator: 'Operator', watchlist: 'Beobachtungsliste', searchPlaceholder: 'Adresse, Tx-Hash, Blocknummer oder Token/Vertrag…', connectWallet: 'Wallet verbinden', darkMode: 'Dunkelmodus', lightMode: 'Hellmodus', back: 'Zurück', exportCsv: 'CSV exportieren', tokenBalances: 'Token-Bestände', internalTxns: 'Interne Transaktionen', readContract: 'Vertrag lesen', writeContract: 'Vertrag schreiben', addToWatchlist: 'Zur Beobachtungsliste', removeFromWatchlist: 'Aus Beobachtungsliste entfernen', checkApprovals: 'Token-Freigaben prüfen', copied: 'Kopiert' },
|
|
fr: { home: 'Accueil', blocks: 'Blocs', transactions: 'Transactions', addresses: 'Adresses', bridge: 'Pont', weth: 'WETH', tokens: 'Jetons', pools: 'Pools', more: 'Plus', analytics: 'Analyses', operator: 'Opérateur', watchlist: 'Liste de suivi', searchPlaceholder: 'Adresse, hash de tx, numéro de bloc ou nom de token/contrat…', connectWallet: 'Connecter le portefeuille', darkMode: 'Mode sombre', lightMode: 'Mode clair', back: 'Retour', exportCsv: 'Exporter CSV', tokenBalances: 'Soldes de jetons', internalTxns: 'Transactions internes', readContract: 'Lire le contrat', writeContract: 'Écrire le contrat', addToWatchlist: 'Ajouter à la liste', removeFromWatchlist: 'Retirer de la liste', checkApprovals: 'Vérifier les approbations', copied: 'Copié' }
|
|
};
|
|
var currentLocale = (function(){ try { return localStorage.getItem('explorerLocale') || 'en'; } catch(e){ return 'en'; } })();
|
|
function t(key) { return (I18N[currentLocale] && I18N[currentLocale][key]) || I18N.en[key] || key; }
|
|
function setLocale(loc) { currentLocale = loc; try { localStorage.setItem('explorerLocale', loc); } catch(e){} if (typeof applyI18n === 'function') applyI18n(); }
|
|
function applyI18n() { document.querySelectorAll('[data-i18n]').forEach(function(el){ var k = el.getAttribute('data-i18n'); if (k) el.textContent = t(k); }); var searchIn = document.getElementById('smartSearchInput'); if (searchIn) searchIn.placeholder = t('searchPlaceholder'); var localeSel = document.getElementById('localeSelect'); if (localeSel) localeSel.value = currentLocale; var wcBtn = document.getElementById('walletConnectBtn'); if (wcBtn) wcBtn.textContent = t('connectWallet'); }
|
|
var _explorerPageFilters = {};
|
|
function normalizeExplorerFilter(value) { return String(value == null ? '' : value).trim().toLowerCase(); }
|
|
function getExplorerPageFilter(key) { return _explorerPageFilters[key] || ''; }
|
|
function setExplorerPageFilter(key, value) { _explorerPageFilters[key] = normalizeExplorerFilter(value); return _explorerPageFilters[key]; }
|
|
function clearExplorerPageFilter(key) { delete _explorerPageFilters[key]; return ''; }
|
|
function matchesExplorerFilter(haystack, filter) { if (!filter) return true; return String(haystack == null ? '' : haystack).toLowerCase().indexOf(filter) !== -1; }
|
|
function escapeAttr(value) { return escapeHtml(String(value == null ? '' : value)).replace(/"/g, '"'); }
|
|
const SMART_SEARCH_HISTORY_KEY = 'explorerSmartSearchHistory';
|
|
const SMART_SEARCH_HISTORY_LIMIT = 8;
|
|
let _smartSearchScope = 'all';
|
|
let _smartSearchPreviewTimer = null;
|
|
let _smartSearchPreviewRequestId = 0;
|
|
let _smartSearchTrendingCache = null;
|
|
function getSmartSearchHistory() {
|
|
try {
|
|
var raw = localStorage.getItem(SMART_SEARCH_HISTORY_KEY);
|
|
var parsed = raw ? JSON.parse(raw) : [];
|
|
return Array.isArray(parsed) ? parsed.filter(Boolean) : [];
|
|
} catch (e) {
|
|
return [];
|
|
}
|
|
}
|
|
function saveSmartSearchHistory(query) {
|
|
var value = String(query || '').trim();
|
|
if (!value) return;
|
|
try {
|
|
var history = getSmartSearchHistory().filter(function(item) {
|
|
return String(item).toLowerCase() !== value.toLowerCase();
|
|
});
|
|
history.unshift(value);
|
|
history = history.slice(0, SMART_SEARCH_HISTORY_LIMIT);
|
|
localStorage.setItem(SMART_SEARCH_HISTORY_KEY, JSON.stringify(history));
|
|
} catch (e) {}
|
|
}
|
|
function detectSmartSearchType(query) {
|
|
var value = String(query || '').trim();
|
|
var normalized = value.replace(/\s/g, '');
|
|
if (!value) return { type: 'recent', label: 'Recent searches', detail: 'Start typing to narrow the explorer.' };
|
|
if (/^0x[a-fA-F0-9]{64}$/.test(normalized)) return { type: 'transaction', label: 'Transaction hash', detail: 'Enter will open the transaction detail page.' };
|
|
if (/^0x[a-fA-F0-9]{40}$/.test(normalized)) return { type: 'address', label: 'Address', detail: 'Enter will open the address detail page.' };
|
|
if (/^\d+$/.test(value)) return { type: 'block', label: 'Block number', detail: 'Enter will open the block detail page.' };
|
|
if (/\.eth$/i.test(value)) return { type: 'ens', label: 'ENS/domain', detail: 'The explorer will search or resolve this name.' };
|
|
if (/^[a-z0-9][a-z0-9._:-]{1,31}$/i.test(value)) return { type: 'token', label: 'Token / asset symbol', detail: 'The explorer will search token and contract matches.' };
|
|
return { type: 'search', label: 'Explorer search', detail: 'The explorer will search across indexed results.' };
|
|
}
|
|
function normalizeSmartSearchItemType(item) {
|
|
var type = String((item && (item.type || item.address_type || item.entity_type || item.kind)) || '').toLowerCase();
|
|
if (item && (item.tx_hash || (item.hash && String(item.hash).length === 66))) return 'transactions';
|
|
if (item && (item.block_number != null)) return 'blocks';
|
|
if (item && (item.token_address || item.token_contract_address_hash)) return 'tokens';
|
|
if (type.indexOf('tx') !== -1 || type.indexOf('transaction') !== -1) return 'transactions';
|
|
if (type.indexOf('block') !== -1) return 'blocks';
|
|
if (type.indexOf('token') !== -1 || type.indexOf('contract') !== -1) return 'tokens';
|
|
if (type.indexOf('address') !== -1) return 'addresses';
|
|
return 'all';
|
|
}
|
|
function setSmartSearchScope(scope) {
|
|
_smartSearchScope = scope || 'all';
|
|
try {
|
|
document.querySelectorAll('.smart-search-scope-btn').forEach(function(btn) {
|
|
var active = btn.getAttribute('data-scope') === _smartSearchScope;
|
|
btn.classList.toggle('btn-primary', active);
|
|
btn.classList.toggle('btn-secondary', !active);
|
|
});
|
|
} catch (e) {}
|
|
var input = document.getElementById('smartSearchInput');
|
|
updateSmartSearchPreview(input ? input.value : '');
|
|
}
|
|
window.setSmartSearchScope = setSmartSearchScope;
|
|
function renderSmartSearchHistory() {
|
|
var history = getSmartSearchHistory();
|
|
if (!history.length) {
|
|
return '<div style="color:var(--text-light);">No recent searches yet.</div>';
|
|
}
|
|
var html = '<div style="display:grid; gap:0.5rem;">';
|
|
history.forEach(function(item) {
|
|
var safeItem = String(item).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
html += '<button type="button" class="btn btn-secondary" style="text-align:left; justify-content:flex-start; padding:0.65rem 0.8rem;" onclick="openSmartSearchModal(\'' + safeItem + '\')"><i class="fas fa-clock" aria-hidden="true" style="margin-right:0.55rem;"></i>' + escapeHtml(item) + '</button>';
|
|
});
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
function renderSmartSearchResultCard(item, action) {
|
|
var type = (item.type || item.address_type || '').toLowerCase();
|
|
var title = item.name || item.symbol || item.address_hash || item.hash || item.tx_hash || (item.block_number != null ? 'Block #' + item.block_number : '') || 'Result';
|
|
var subtitle = item.symbol && item.name ? item.symbol + ' • ' + item.name : item.symbol || item.name || item.address_hash || item.hash || item.tx_hash || '';
|
|
var meta = [];
|
|
if (item.address_hash || item.hash || item.token_address || item.token_contract_address_hash) {
|
|
meta.push(shortenHash(item.address_hash || item.hash || item.token_address || item.token_contract_address_hash));
|
|
}
|
|
if (item.block_number != null) meta.push('Block #' + item.block_number);
|
|
if (item.transaction_count != null) meta.push(String(item.transaction_count) + ' tx');
|
|
var html = '<button type="button" class="btn btn-secondary" style="width:100%; text-align:left; justify-content:flex-start; padding:0.85rem 0.95rem; border-radius:16px; border:1px solid var(--border); background:linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0.08));"' + (action ? ' onclick="' + action + '; closeSmartSearchModal();"' : ' disabled') + '>';
|
|
html += '<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:0.85rem; width:100%;">';
|
|
html += '<div style="min-width:0; flex:1;">';
|
|
html += '<div style="display:flex; align-items:center; gap:0.5rem; flex-wrap:wrap; margin-bottom:0.35rem;">';
|
|
html += '<span style="font-size:0.72rem; text-transform:uppercase; letter-spacing:0.08em; color:var(--text-light);">' + escapeHtml(type || 'result') + '</span>';
|
|
html += '<span style="padding:0.18rem 0.45rem; border-radius:999px; background:rgba(37,99,235,0.12); color:var(--primary); font-size:0.72rem;">' + escapeHtml(type ? type.toUpperCase() : 'MATCH') + '</span>';
|
|
html += '</div>';
|
|
html += '<div style="font-size:0.98rem; font-weight:700; line-height:1.3; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">' + escapeHtml(String(title).substring(0, 96)) + '</div>';
|
|
if (subtitle) {
|
|
html += '<div style="color:var(--text-light); font-size:0.84rem; line-height:1.35; margin-top:0.2rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;">' + escapeHtml(String(subtitle).substring(0, 120)) + '</div>';
|
|
}
|
|
if (meta.length) {
|
|
html += '<div style="display:flex; flex-wrap:wrap; gap:0.35rem; margin-top:0.55rem;">';
|
|
meta.forEach(function(bit) {
|
|
html += '<span style="padding:0.18rem 0.45rem; border:1px solid var(--border); border-radius:999px; font-size:0.72rem; color:var(--text-light);">' + escapeHtml(bit) + '</span>';
|
|
});
|
|
html += '</div>';
|
|
}
|
|
html += '</div>';
|
|
html += '<div style="display:flex; align-items:center; color:var(--text-light); padding-top:0.1rem;"><i class="fas fa-arrow-right"></i></div>';
|
|
html += '</div>';
|
|
html += '</button>';
|
|
return html;
|
|
}
|
|
function renderSmartSearchPreviewItems(items, query) {
|
|
if (!items || !items.length) {
|
|
return '<div style="color:var(--text-light);">No live suggestions yet. Press Enter to search everything, or try a more specific address, hash, block, or token symbol.</div>';
|
|
}
|
|
var html = '<div style="display:grid; gap:0.45rem;">';
|
|
items.slice(0, 6).forEach(function(item) {
|
|
if (_smartSearchScope !== 'all' && normalizeSmartSearchItemType(item) !== _smartSearchScope) return;
|
|
var action = null;
|
|
if (item.token_address || item.token_contract_address_hash) {
|
|
var tokenAddr = item.token_address || item.token_contract_address_hash;
|
|
if (/^0x[a-f0-9]{40}$/i.test(tokenAddr)) action = 'showTokenDetail(\'' + escapeHtml(tokenAddr) + '\')';
|
|
}
|
|
if (!action && (item.address_hash || item.hash) && /^0x[a-f0-9]{40}$/i.test(item.address_hash || item.hash)) {
|
|
var addr = item.address_hash || item.hash;
|
|
action = 'showAddressDetail(\'' + escapeHtml(addr) + '\')';
|
|
}
|
|
if (!action && (item.tx_hash || (item.hash && item.hash.length === 66)) && /^0x[a-f0-9]{64}$/i.test(item.tx_hash || item.hash)) {
|
|
var txHash = item.tx_hash || item.hash;
|
|
action = 'showTransactionDetail(\'' + escapeHtml(txHash) + '\')';
|
|
}
|
|
if (!action && item.block_number != null) {
|
|
action = 'showBlockDetail(\'' + escapeHtml(String(item.block_number)) + '\')';
|
|
}
|
|
html += renderSmartSearchResultCard(item, action);
|
|
});
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
function renderSmartSearchTrendingTokens(tokens) {
|
|
if (!tokens || !tokens.length) {
|
|
return '<div style="color:var(--text-light);">No trending tokens found yet.</div>';
|
|
}
|
|
var html = '<div style="display:grid; gap:0.45rem;">';
|
|
tokens.slice(0, 6).forEach(function(token) {
|
|
var tokenAddr = (token.address && (token.address.hash || token.address)) || token.address_hash || token.token_address || token.contract_address_hash || '';
|
|
if (!tokenAddr) return;
|
|
var item = {
|
|
type: 'token',
|
|
name: token.name || token.symbol || 'Token',
|
|
symbol: token.symbol || '',
|
|
token_address: tokenAddr
|
|
};
|
|
html += renderSmartSearchResultCard(item, 'showTokenDetail(\'' + String(tokenAddr).replace(/\\/g, '\\\\').replace(/'/g, "\\'") + '\')');
|
|
});
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
async function fetchSmartSearchTrendingTokens() {
|
|
if (Array.isArray(_smartSearchTrendingCache)) return _smartSearchTrendingCache;
|
|
try {
|
|
var resp = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/tokens?page=1&page_size=6');
|
|
var items = (resp && (resp.items || resp.data)) || [];
|
|
_smartSearchTrendingCache = Array.isArray(items) ? items : [];
|
|
} catch (e) {
|
|
_smartSearchTrendingCache = [];
|
|
}
|
|
return _smartSearchTrendingCache;
|
|
}
|
|
async function updateSmartSearchPreview(query) {
|
|
var input = document.getElementById('smartSearchInput');
|
|
var preview = document.getElementById('smartSearchPreview');
|
|
var detected = document.getElementById('smartSearchDetected');
|
|
if (!preview || !detected) return;
|
|
|
|
var info = detectSmartSearchType(query);
|
|
if (!query) {
|
|
detected.style.display = 'inline-block';
|
|
detected.textContent = _smartSearchScope === 'all' ? 'All' : (_smartSearchScope.charAt(0).toUpperCase() + _smartSearchScope.slice(1));
|
|
if (input) input.setAttribute('aria-describedby', 'smartSearchDetected');
|
|
var emptyRequestId = ++_smartSearchPreviewRequestId;
|
|
preview.innerHTML = '<div style="display:grid; gap:1rem;"><div><div style="font-weight:700; margin-bottom:0.45rem;">Recent searches</div>' + renderSmartSearchHistory() + '</div><div><div style="font-weight:700; margin-bottom:0.45rem;">Trending tokens</div><div style="color:var(--text-light);">Loading trending token watchlist...</div></div><div><div style="font-weight:700; margin-bottom:0.45rem;">Scope</div><div style="color:var(--text-light); line-height:1.6;">Current filter: <strong>' + escapeHtml(_smartSearchScope) + '</strong>. Use the left rail to narrow the search feed.</div></div><div><div style="font-weight:700; margin-bottom:0.45rem;">Quick tips</div><div style="color:var(--text-light); line-height:1.6;">Try an address, transaction hash, block number, token symbol, or contract name. Press <strong>Enter</strong> to search and <strong>Esc</strong> to close.</div></div></div>';
|
|
fetchSmartSearchTrendingTokens().then(function(tokens) {
|
|
if (emptyRequestId !== _smartSearchPreviewRequestId) return;
|
|
preview.innerHTML = '<div style="display:grid; gap:1rem;"><div><div style="font-weight:700; margin-bottom:0.45rem;">Recent searches</div>' + renderSmartSearchHistory() + '</div><div><div style="font-weight:700; margin-bottom:0.45rem;">Trending tokens</div>' + renderSmartSearchTrendingTokens(tokens) + '</div><div><div style="font-weight:700; margin-bottom:0.45rem;">Scope</div><div style="color:var(--text-light); line-height:1.6;">Current filter: <strong>' + escapeHtml(_smartSearchScope) + '</strong>. Use the left rail to narrow the search feed.</div></div><div><div style="font-weight:700; margin-bottom:0.45rem;">Quick tips</div><div style="color:var(--text-light); line-height:1.6;">Try an address, transaction hash, block number, token symbol, or contract name. Press <strong>Enter</strong> to search and <strong>Esc</strong> to close.</div></div></div>';
|
|
});
|
|
return;
|
|
}
|
|
|
|
detected.style.display = 'inline-block';
|
|
detected.textContent = _smartSearchScope === 'all' ? info.label : (_smartSearchScope.charAt(0).toUpperCase() + _smartSearchScope.slice(1));
|
|
preview.innerHTML = '<div style="color:var(--text-light);">Searching live suggestions...</div>';
|
|
|
|
var requestId = ++_smartSearchPreviewRequestId;
|
|
if (_smartSearchPreviewTimer) {
|
|
clearTimeout(_smartSearchPreviewTimer);
|
|
_smartSearchPreviewTimer = null;
|
|
}
|
|
_smartSearchPreviewTimer = setTimeout(async function() {
|
|
try {
|
|
var liveItems = [];
|
|
if (CHAIN_ID === 138) {
|
|
var resp = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/search?q=' + encodeURIComponent(query));
|
|
if (requestId !== _smartSearchPreviewRequestId) return;
|
|
liveItems = (resp && resp.items) ? resp.items : [];
|
|
}
|
|
if (requestId !== _smartSearchPreviewRequestId) return;
|
|
if (_smartSearchScope !== 'all') {
|
|
liveItems = liveItems.filter(function(item) {
|
|
return normalizeSmartSearchItemType(item) === _smartSearchScope;
|
|
});
|
|
}
|
|
preview.innerHTML = '<div style="display:grid; gap:1rem;"><div><div style="font-weight:700; margin-bottom:0.45rem;">Detected: ' + escapeHtml(info.label) + '</div><div style="color:var(--text-light); line-height:1.5;">' + escapeHtml(info.detail) + '</div></div><div><div style="font-weight:700; margin-bottom:0.45rem;">Live suggestions</div>' + renderSmartSearchPreviewItems(liveItems, query) + '</div></div>';
|
|
} catch (error) {
|
|
if (requestId !== _smartSearchPreviewRequestId) return;
|
|
if (_smartSearchScope !== 'all') {
|
|
preview.innerHTML = '<div style="display:grid; gap:1rem;"><div><div style="font-weight:700; margin-bottom:0.45rem;">Detected: ' + escapeHtml(info.label) + '</div><div style="color:var(--text-light); line-height:1.5;">' + escapeHtml(info.detail) + '</div></div><div style="color:var(--text-light);">Live suggestions unavailable for the selected scope. Press Enter to search directly.</div></div>';
|
|
return;
|
|
}
|
|
preview.innerHTML = '<div style="display:grid; gap:1rem;"><div><div style="font-weight:700; margin-bottom:0.45rem;">Detected: ' + escapeHtml(info.label) + '</div><div style="color:var(--text-light); line-height:1.5;">' + escapeHtml(info.detail) + '</div></div><div style="color:var(--text-light);">Live suggestions unavailable. Press Enter to search directly.</div></div>';
|
|
}
|
|
}, 220);
|
|
}
|
|
function openSmartSearchModal(prefill) {
|
|
var modal = document.getElementById('smartSearchModal');
|
|
var input = document.getElementById('smartSearchInput');
|
|
if (!modal || !input) return;
|
|
modal.style.display = 'block';
|
|
modal.setAttribute('aria-hidden', 'false');
|
|
document.body.style.overflow = 'hidden';
|
|
setSmartSearchScope(_smartSearchScope || 'all');
|
|
if (prefill != null) {
|
|
input.value = prefill;
|
|
}
|
|
updateSmartSearchPreview(input.value || '');
|
|
setTimeout(function() {
|
|
input.focus();
|
|
input.select();
|
|
}, 0);
|
|
}
|
|
function closeSmartSearchModal() {
|
|
var modal = document.getElementById('smartSearchModal');
|
|
if (!modal) return;
|
|
modal.style.display = 'none';
|
|
modal.setAttribute('aria-hidden', 'true');
|
|
document.body.style.overflow = '';
|
|
}
|
|
window.openSmartSearchModal = openSmartSearchModal;
|
|
window.closeSmartSearchModal = closeSmartSearchModal;
|
|
function renderPageFilterBar(key, placeholder, helperText, reloadJs) {
|
|
var inputId = key + 'FilterInput';
|
|
var value = getExplorerPageFilter(key);
|
|
var safeReload = reloadJs ? String(reloadJs) : '';
|
|
var html = '<div style="display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; margin: 0 0 0.75rem 0; padding: 0.75rem 0.9rem; border: 1px solid var(--border); border-radius: 10px; background: var(--light);">';
|
|
html += '<input id="' + inputId + '" type="search" value="' + escapeAttr(value) + '" placeholder="' + escapeAttr(placeholder || 'Filter...') + '" style="flex: 1 1 260px; min-width: 220px; padding: 0.55rem 0.7rem; border: 1px solid var(--border); border-radius: 8px; background: var(--light); color: var(--text);" onkeydown="if(event.key===\'Enter\'){event.preventDefault(); setExplorerPageFilter(\'' + key + '\', this.value); ' + safeReload + ';}">';
|
|
html += '<button type="button" class="btn btn-primary" onclick="setExplorerPageFilter(\'' + key + '\', document.getElementById(\'' + inputId + '\').value); ' + safeReload + ';">Apply</button>';
|
|
html += '<button type="button" class="btn btn-secondary" onclick="clearExplorerPageFilter(\'' + key + '\'); var el=document.getElementById(\'' + inputId + '\'); if(el) el.value=\'\'; ' + safeReload + ';">Clear</button>';
|
|
if (helperText) html += '<span style="color: var(--text-light); font-size: 0.85rem;">' + escapeHtml(helperText) + '</span>';
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
window.setExplorerPageFilter = setExplorerPageFilter;
|
|
window.clearExplorerPageFilter = clearExplorerPageFilter;
|
|
window._renderWatchlist = function() {
|
|
var container = document.getElementById('watchlistContent');
|
|
if (!container) return;
|
|
var list = getWatchlist();
|
|
var filter = getExplorerPageFilter('watchlist');
|
|
var filtered = filter ? list.filter(function(addr) {
|
|
return matchesExplorerFilter([addr, getAddressLabel(addr) || ''].join(' '), filter);
|
|
}) : list;
|
|
var filterBar = renderPageFilterBar('watchlist', 'Filter by address or label...', 'Filters your saved addresses.', 'window._renderWatchlist && window._renderWatchlist()');
|
|
if (list.length === 0) { container.innerHTML = filterBar + '<p style="color: var(--text-light);">No addresses in watchlist. Open an address and click "Add to watchlist".</p>'; return; }
|
|
if (filtered.length === 0) { container.innerHTML = filterBar + '<p style="color: var(--text-light);">No watchlist entries match the current filter.</p>'; return; }
|
|
var html = filterBar + '<table class="table"><thead><tr><th>Address</th><th>Label</th><th></th></tr></thead><tbody>';
|
|
filtered.forEach(function(addr){ var label = getAddressLabel(addr) || ''; html += '<tr><td>' + explorerAddressLink(addr, escapeHtml(shortenHash(addr)), 'color: inherit; text-decoration: none;') + '</td><td>' + escapeHtml(label) + '</td><td><button type="button" class="btn btn-secondary" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;" onclick="event.stopPropagation(); removeFromWatchlist(\'' + escapeHtml(addr) + '\'); if(window._renderWatchlist) window._renderWatchlist();">Remove</button></td></tr>'; });
|
|
html += '</tbody></table>';
|
|
container.innerHTML = html;
|
|
};
|
|
var KNOWN_ADDRESS_LABELS = { '0x89dd12025bfcd38a168455a44b400e913ed33be2': 'CCIP WETH9 Bridge', '0xe0e93247376aa097db308b92e6ba36ba015535d0': 'CCIP WETH10 Bridge', '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2': 'WETH9', '0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f': 'WETH10', '0x8078a09637e47fa5ed34f626046ea2094a5cde5e': 'CCIP Router', '0x105f8a15b819948a89153505762444ee9f324684': 'CCIP Sender' };
|
|
const CHAIN_138_PMM_INTEGRATION_ADDRESS = '0x5BDc62f1ae7D630c37A8B363a1d49845356Ee72d';
|
|
const CHAIN_138_PRIVATE_POOL_REGISTRY = '0xb27057B27db09e8Df353AF722c299f200519882A';
|
|
const CHAIN_138_CUSDT_ADDRESS = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22';
|
|
const CHAIN_138_CUSDC_ADDRESS = '0xf22258f57794CC8E06237084b353Ab30fFfa640b';
|
|
const CHAIN_138_CEURT_ADDRESS = '0xdf4b71c61E5912712C1Bdd451416B9aC26949d72';
|
|
const CHAIN_138_CXAUC_ADDRESS = '0x290E52a8819A4fbD0714E517225429aA2B70EC6b';
|
|
const CHAIN_138_CXAUT_ADDRESS = '0x94e408E26c6FD8F4ee00b54dF19082FDA07dC96E';
|
|
const MAINNET_USDT_ADDRESS = '0xdAC17F958D2ee523a2206206994597C13D831ec7';
|
|
const MAINNET_USDC_ADDRESS = '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48';
|
|
const RESERVE_SYSTEM_ADDRESS = '0x607e97cD626f209facfE48c1464815DDE15B5093';
|
|
const RESERVE_TOKEN_INTEGRATION_ADDRESS = '0x34B73e6EDFd9f85a7c25EeD31dcB13aB6E969b96';
|
|
const BRIDGE_VAULT_ADDRESS = '0x31884f84555210FFB36a19D2471b8eBc7372d0A8';
|
|
const CHAIN_138_POOL_BALANCE_DECIMALS = 6;
|
|
const SELECTOR_POOLS = '0x901754d7';
|
|
const SELECTOR_GET_PRIVATE_POOL = '0xc427540d';
|
|
const SELECTOR_BALANCE_OF = '0x70a08231';
|
|
const SELECTOR_SYMBOL = '0x95d89b41';
|
|
const SELECTOR_OFFICIAL_USDT = '0xe015a3b8';
|
|
const SELECTOR_OFFICIAL_USDC = '0xc82ab874';
|
|
const SELECTOR_COMPLIANT_USDT = '0x15fdffdf';
|
|
const SELECTOR_COMPLIANT_USDC = '0x59916868';
|
|
const ROUTE_TREE_REFRESH_MS = 120000;
|
|
function buildRoutePriorityQueries(ctx) {
|
|
var officialUsdt = (ctx && ctx.officialUSDT) || '';
|
|
var officialUsdc = (ctx && ctx.officialUSDC) || '';
|
|
var queries = [
|
|
{
|
|
key: 'local-cusdt-cusdc',
|
|
title: 'Local direct: cUSDT / cUSDC',
|
|
tokenIn: CHAIN_138_CUSDT_ADDRESS,
|
|
tokenOut: CHAIN_138_CUSDC_ADDRESS,
|
|
destinationChainId: 138,
|
|
amountIn: '1000000',
|
|
},
|
|
{
|
|
key: 'bridge-cusdt-usdt',
|
|
title: 'Mainnet bridge path: cUSDT -> USDT',
|
|
tokenIn: CHAIN_138_CUSDT_ADDRESS,
|
|
tokenOut: MAINNET_USDT_ADDRESS,
|
|
destinationChainId: 1,
|
|
amountIn: '1000000',
|
|
},
|
|
{
|
|
key: 'bridge-cusdc-usdc',
|
|
title: 'Mainnet bridge path: cUSDC -> USDC',
|
|
tokenIn: CHAIN_138_CUSDC_ADDRESS,
|
|
tokenOut: MAINNET_USDC_ADDRESS,
|
|
destinationChainId: 1,
|
|
amountIn: '1000000',
|
|
}
|
|
];
|
|
if (safeAddress(officialUsdt)) {
|
|
queries.unshift({
|
|
key: 'local-cusdt-usdt',
|
|
title: 'Local direct: cUSDT / USDT (official mirror, Chain 138)',
|
|
tokenIn: CHAIN_138_CUSDT_ADDRESS,
|
|
tokenOut: officialUsdt,
|
|
destinationChainId: 138,
|
|
amountIn: '1000000',
|
|
});
|
|
}
|
|
if (safeAddress(officialUsdc)) {
|
|
queries.splice(2, 0, {
|
|
key: 'local-cusdc-usdc',
|
|
title: 'Local direct: cUSDC / USDC (official mirror, Chain 138)',
|
|
tokenIn: CHAIN_138_CUSDC_ADDRESS,
|
|
tokenOut: officialUsdc,
|
|
destinationChainId: 138,
|
|
amountIn: '1000000',
|
|
});
|
|
}
|
|
return queries;
|
|
}
|
|
const CHAIN_138_ROUTE_SWEEP_TOKENS = [
|
|
{ symbol: 'cUSDT', address: '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22' },
|
|
{ symbol: 'cUSDC', address: '0xf22258f57794CC8E06237084b353Ab30fFfa640b' },
|
|
{ symbol: 'cEURC', address: '0x8085961F9cF02b4d800A3c6d386D31da4B34266a' },
|
|
{ symbol: 'cEURT', address: '0xdf4b71c61E5912712C1Bdd451416B9aC26949d72' },
|
|
{ symbol: 'cGBPC', address: '0x003960f16D9d34F2e98d62723B6721Fb92074aD2' },
|
|
{ symbol: 'cGBPT', address: '0x350f54e4D23795f86A9c03988c7135357CCaD97c' },
|
|
{ symbol: 'cAUDC', address: '0xD51482e567c03899eecE3CAe8a058161FD56069D' },
|
|
{ symbol: 'cJPYC', address: '0xEe269e1226a334182aace90056EE4ee5Cc8A6770' },
|
|
{ symbol: 'cCHFC', address: '0x873990849DDa5117d7C644f0aF24370797C03885' },
|
|
{ symbol: 'cCADC', address: '0x54dBd40cF05e15906A2C21f600937e96787f5679' },
|
|
{ symbol: 'cXAUC', address: '0x290E52a8819A4fbD0714E517225429aA2B70EC6b' },
|
|
{ symbol: 'cXAUT', address: '0x94e408E26c6FD8F4ee00b54dF19082FDA07dC96E' }
|
|
];
|
|
function buildRouteSweepQueries(ctx) {
|
|
var officialUsdt = (ctx && ctx.officialUSDT) || '';
|
|
var officialUsdc = (ctx && ctx.officialUSDC) || '';
|
|
var queries = [];
|
|
CHAIN_138_ROUTE_SWEEP_TOKENS.forEach(function(token) {
|
|
var anchors = [];
|
|
if (token.symbol === 'cUSDT') {
|
|
anchors.push({ symbol: 'cUSDC', address: CHAIN_138_CUSDC_ADDRESS });
|
|
if (safeAddress(officialUsdt)) anchors.push({ symbol: 'USDT', address: officialUsdt });
|
|
} else if (token.symbol === 'cUSDC') {
|
|
anchors.push({ symbol: 'cUSDT', address: CHAIN_138_CUSDT_ADDRESS });
|
|
if (safeAddress(officialUsdc)) anchors.push({ symbol: 'USDC', address: officialUsdc });
|
|
} else {
|
|
anchors.push({ symbol: 'cUSDT', address: CHAIN_138_CUSDT_ADDRESS });
|
|
anchors.push({ symbol: 'cUSDC', address: CHAIN_138_CUSDC_ADDRESS });
|
|
}
|
|
anchors.forEach(function(anchor) {
|
|
if (!safeAddress(anchor.address)) return;
|
|
if (String(anchor.address).toLowerCase() === String(token.address).toLowerCase()) return;
|
|
queries.push({
|
|
key: token.symbol.toLowerCase() + '-' + anchor.symbol.toLowerCase(),
|
|
title: token.symbol + ' / ' + anchor.symbol + ' coverage probe',
|
|
symbol: token.symbol,
|
|
pairLabel: token.symbol + ' / ' + anchor.symbol,
|
|
tokenIn: token.address,
|
|
tokenOut: anchor.address,
|
|
destinationChainId: 138,
|
|
amountIn: '1000000'
|
|
});
|
|
});
|
|
});
|
|
return queries;
|
|
}
|
|
function stripHexPrefix(value) {
|
|
return String(value || '').replace(/^0x/i, '');
|
|
}
|
|
function encodeAddressWord(address) {
|
|
return stripHexPrefix(address).toLowerCase().padStart(64, '0');
|
|
}
|
|
function decodeAddressWord(data) {
|
|
var hex = stripHexPrefix(data);
|
|
if (!hex || hex.length < 64) return '';
|
|
return '0x' + hex.slice(hex.length - 40);
|
|
}
|
|
function decodeUint256Word(data, offsetWords) {
|
|
var hex = stripHexPrefix(data);
|
|
var start = (offsetWords || 0) * 64;
|
|
var word = hex.slice(start, start + 64);
|
|
if (!word) return 0n;
|
|
try { return BigInt('0x' + word); } catch (e) { return 0n; }
|
|
}
|
|
async function rpcEthCall(to, data) {
|
|
return rpcCall('eth_call', [{ to: to, data: data }, 'latest']);
|
|
}
|
|
async function rpcCodeExists(address) {
|
|
if (!safeAddress(address)) return false;
|
|
try {
|
|
var code = await rpcCall('eth_getCode', [address, 'latest']);
|
|
return !!code && code !== '0x';
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
}
|
|
async function rpcReadAddress(to, selector) {
|
|
if (!safeAddress(to)) return '';
|
|
try {
|
|
var out = await rpcEthCall(to, selector);
|
|
var addr = decodeAddressWord(out);
|
|
return safeAddress(addr) ? addr : '';
|
|
} catch (e) {
|
|
return '';
|
|
}
|
|
}
|
|
async function rpcReadMappedPool(integration, tokenA, tokenB) {
|
|
if (!safeAddress(integration) || !safeAddress(tokenA) || !safeAddress(tokenB)) return '';
|
|
try {
|
|
var out = await rpcEthCall(integration, SELECTOR_POOLS + encodeAddressWord(tokenA) + encodeAddressWord(tokenB));
|
|
var addr = decodeAddressWord(out);
|
|
return safeAddress(addr) ? addr : '';
|
|
} catch (e) {
|
|
return '';
|
|
}
|
|
}
|
|
async function rpcReadPrivatePool(registry, tokenA, tokenB) {
|
|
if (!safeAddress(registry) || !safeAddress(tokenA) || !safeAddress(tokenB)) return '';
|
|
try {
|
|
var out = await rpcEthCall(registry, SELECTOR_GET_PRIVATE_POOL + encodeAddressWord(tokenA) + encodeAddressWord(tokenB));
|
|
var addr = decodeAddressWord(out);
|
|
return safeAddress(addr) ? addr : '';
|
|
} catch (e) {
|
|
return '';
|
|
}
|
|
}
|
|
async function rpcReadBalanceOf(token, account) {
|
|
if (!safeAddress(token) || !safeAddress(account)) return 0n;
|
|
try {
|
|
var out = await rpcEthCall(token, SELECTOR_BALANCE_OF + encodeAddressWord(account));
|
|
return decodeUint256Word(out, 0);
|
|
} catch (e) {
|
|
return 0n;
|
|
}
|
|
}
|
|
async function rpcReadSymbol(token) {
|
|
if (!safeAddress(token)) return '';
|
|
try {
|
|
var out = await rpcEthCall(token, SELECTOR_SYMBOL);
|
|
var hex = stripHexPrefix(out);
|
|
if (!hex || hex.length < 128) return '';
|
|
var len = Number(BigInt('0x' + hex.slice(64, 128)));
|
|
if (!len || !Number.isFinite(len)) return '';
|
|
var dataHex = hex.slice(128, 128 + (len * 2));
|
|
var bytes = [];
|
|
for (var i = 0; i < dataHex.length; i += 2) {
|
|
bytes.push(parseInt(dataHex.slice(i, i + 2), 16));
|
|
}
|
|
return new TextDecoder().decode(new Uint8Array(bytes)).replace(/\0+$/, '');
|
|
} catch (e) {
|
|
return '';
|
|
}
|
|
}
|
|
function formatTokenUnits(value, decimals, precision) {
|
|
var amount = typeof value === 'bigint' ? value : BigInt(value || 0);
|
|
var scale = BigInt(Math.pow(10, Number(decimals || 0)));
|
|
if (scale === 0n) return '0';
|
|
var whole = amount / scale;
|
|
var fraction = amount % scale;
|
|
if (fraction === 0n) return whole.toString();
|
|
var fracStr = fraction.toString().padStart(Number(decimals || 0), '0');
|
|
var trimmed = fracStr.slice(0, Math.max(0, Number(precision == null ? 3 : precision))).replace(/0+$/, '');
|
|
return trimmed ? (whole.toString() + '.' + trimmed) : whole.toString();
|
|
}
|
|
async function fetchCurrentPmmContext() {
|
|
var integration = CHAIN_138_PMM_INTEGRATION_ADDRESS;
|
|
var officialUSDT = await rpcReadAddress(integration, SELECTOR_OFFICIAL_USDT);
|
|
var officialUSDC = await rpcReadAddress(integration, SELECTOR_OFFICIAL_USDC);
|
|
var compliantUSDT = await rpcReadAddress(integration, SELECTOR_COMPLIANT_USDT);
|
|
var compliantUSDC = await rpcReadAddress(integration, SELECTOR_COMPLIANT_USDC);
|
|
return {
|
|
integration: integration,
|
|
privateRegistry: CHAIN_138_PRIVATE_POOL_REGISTRY,
|
|
officialUSDT: officialUSDT,
|
|
officialUSDC: officialUSDC,
|
|
compliantUSDT: compliantUSDT || CHAIN_138_CUSDT_ADDRESS,
|
|
compliantUSDC: compliantUSDC || CHAIN_138_CUSDC_ADDRESS
|
|
};
|
|
}
|
|
function inferKnownTokenSymbol(address, ctx) {
|
|
var lower = String(address || '').toLowerCase();
|
|
if (lower === String((ctx && ctx.compliantUSDT) || CHAIN_138_CUSDT_ADDRESS).toLowerCase()) return 'cUSDT';
|
|
if (lower === String((ctx && ctx.compliantUSDC) || CHAIN_138_CUSDC_ADDRESS).toLowerCase()) return 'cUSDC';
|
|
if (lower === String((ctx && ctx.officialUSDT) || '').toLowerCase()) return 'USDT';
|
|
if (lower === String((ctx && ctx.officialUSDC) || '').toLowerCase()) return 'USDC';
|
|
var matched = CHAIN_138_ROUTE_SWEEP_TOKENS.find(function(token) { return String(token.address).toLowerCase() === lower; });
|
|
return matched ? matched.symbol : '';
|
|
}
|
|
async function buildLiveDirectRouteFallback(query, ctx) {
|
|
if (!query || Number(query.destinationChainId || query.chainId || 138) !== 138 || !safeAddress(query.tokenOut)) return null;
|
|
var poolAddress = await rpcReadMappedPool((ctx && ctx.integration) || CHAIN_138_PMM_INTEGRATION_ADDRESS, query.tokenIn, query.tokenOut);
|
|
if (!safeAddress(poolAddress) || !(await rpcCodeExists(poolAddress))) return null;
|
|
|
|
var reserveIn = await rpcReadBalanceOf(query.tokenIn, poolAddress);
|
|
var reserveOut = await rpcReadBalanceOf(query.tokenOut, poolAddress);
|
|
var funded = reserveIn > 0n && reserveOut > 0n;
|
|
var partial = (reserveIn > 0n || reserveOut > 0n) && !funded;
|
|
var status = funded ? 'live' : (partial ? 'partial' : 'unavailable');
|
|
var tokenInSymbol = inferKnownTokenSymbol(query.tokenIn, ctx) || await rpcReadSymbol(query.tokenIn) || shortenHash(query.tokenIn);
|
|
var tokenOutSymbol = inferKnownTokenSymbol(query.tokenOut, ctx) || await rpcReadSymbol(query.tokenOut) || shortenHash(query.tokenOut);
|
|
var reserveInFormatted = formatTokenUnits(reserveIn, CHAIN_138_POOL_BALANCE_DECIMALS, 3);
|
|
var reserveOutFormatted = formatTokenUnits(reserveOut, CHAIN_138_POOL_BALANCE_DECIMALS, 3);
|
|
|
|
return {
|
|
generatedAt: new Date().toISOString(),
|
|
source: {
|
|
chainId: 138,
|
|
chainName: 'DeFi Oracle Meta Mainnet',
|
|
tokenIn: {
|
|
address: query.tokenIn,
|
|
symbol: tokenInSymbol,
|
|
name: tokenInSymbol,
|
|
decimals: CHAIN_138_POOL_BALANCE_DECIMALS,
|
|
source: 'live-rpc'
|
|
}
|
|
},
|
|
destination: {
|
|
chainId: 138,
|
|
chainName: 'DeFi Oracle Meta Mainnet'
|
|
},
|
|
decision: 'direct-pool',
|
|
tree: [{
|
|
id: 'live-direct:' + String(poolAddress).toLowerCase(),
|
|
kind: 'direct-pool',
|
|
label: tokenInSymbol + '/' + tokenOutSymbol,
|
|
chainId: 138,
|
|
chainName: 'DeFi Oracle Meta Mainnet',
|
|
status: status,
|
|
depth: {
|
|
tvlUsd: 0,
|
|
reserve0: reserveInFormatted,
|
|
reserve1: reserveOutFormatted,
|
|
estimatedTradeCapacityUsd: 0,
|
|
freshnessSeconds: 0,
|
|
status: funded ? 'live' : (partial ? 'stale' : 'unavailable')
|
|
},
|
|
tokenIn: {
|
|
address: query.tokenIn,
|
|
symbol: tokenInSymbol,
|
|
name: tokenInSymbol,
|
|
decimals: CHAIN_138_POOL_BALANCE_DECIMALS,
|
|
source: 'live-rpc'
|
|
},
|
|
tokenOut: {
|
|
address: query.tokenOut,
|
|
symbol: tokenOutSymbol,
|
|
name: tokenOutSymbol,
|
|
decimals: CHAIN_138_POOL_BALANCE_DECIMALS,
|
|
source: 'live-rpc'
|
|
},
|
|
poolAddress: poolAddress,
|
|
dexType: 'DODO PMM',
|
|
path: [query.tokenIn, query.tokenOut],
|
|
notes: [
|
|
'Live fallback from on-chain DODOPMMIntegration mapping',
|
|
'Base reserve ' + reserveInFormatted,
|
|
'Quote reserve ' + reserveOutFormatted
|
|
]
|
|
}],
|
|
pools: [{
|
|
poolAddress: poolAddress,
|
|
dexType: 'DODO PMM',
|
|
token0: {
|
|
address: query.tokenIn,
|
|
symbol: tokenInSymbol,
|
|
name: tokenInSymbol,
|
|
decimals: CHAIN_138_POOL_BALANCE_DECIMALS,
|
|
source: 'live-rpc'
|
|
},
|
|
token1: {
|
|
address: query.tokenOut,
|
|
symbol: tokenOutSymbol,
|
|
name: tokenOutSymbol,
|
|
decimals: CHAIN_138_POOL_BALANCE_DECIMALS,
|
|
source: 'live-rpc'
|
|
},
|
|
depth: {
|
|
tvlUsd: 0,
|
|
reserve0: reserveInFormatted,
|
|
reserve1: reserveOutFormatted,
|
|
estimatedTradeCapacityUsd: 0,
|
|
freshnessSeconds: 0,
|
|
status: funded ? 'live' : (partial ? 'stale' : 'unavailable')
|
|
}
|
|
}],
|
|
missingQuoteTokenPools: []
|
|
};
|
|
}
|
|
async function discoverPublicXauPool(baseToken, anchors, integration) {
|
|
for (var i = 0; i < anchors.length; i++) {
|
|
var anchor = anchors[i];
|
|
var pool = await rpcReadMappedPool(integration, baseToken, anchor.address);
|
|
if (safeAddress(pool)) {
|
|
return { pool: pool, anchor: anchor };
|
|
}
|
|
}
|
|
return { pool: '', anchor: anchors[0] };
|
|
}
|
|
async function discoverPrivateXauPool(baseToken, anchors, registry) {
|
|
for (var i = 0; i < anchors.length; i++) {
|
|
var anchor = anchors[i];
|
|
var pool = await rpcReadPrivatePool(registry, baseToken, anchor.address);
|
|
if (safeAddress(pool)) {
|
|
return { pool: pool, anchor: anchor };
|
|
}
|
|
}
|
|
return { pool: '', anchor: anchors[0] };
|
|
}
|
|
async function getLivePoolRows() {
|
|
var ctx = await fetchCurrentPmmContext();
|
|
var xauAnchors = [
|
|
{ symbol: 'cXAUC', address: CHAIN_138_CXAUC_ADDRESS },
|
|
{ symbol: 'cXAUT', address: CHAIN_138_CXAUT_ADDRESS }
|
|
];
|
|
var rows = [];
|
|
|
|
async function buildLivePoolRow(category, poolPair, poolType, poolAddress, baseToken, quoteToken, notesPrefix) {
|
|
var codeExists = await rpcCodeExists(poolAddress);
|
|
var baseBal = codeExists ? await rpcReadBalanceOf(baseToken, poolAddress) : 0n;
|
|
var quoteBal = codeExists ? await rpcReadBalanceOf(quoteToken, poolAddress) : 0n;
|
|
var funded = baseBal > 0n && quoteBal > 0n;
|
|
var partial = (baseBal > 0n || quoteBal > 0n) && !funded;
|
|
var status = !safeAddress(poolAddress) ? 'Not created' : (funded ? 'Funded (live)' : (partial ? 'Partially funded' : (codeExists ? 'Created (unfunded)' : 'Missing code')));
|
|
var notes = [];
|
|
if (notesPrefix) notes.push(notesPrefix);
|
|
if (safeAddress(poolAddress) && codeExists) {
|
|
notes.push('Base reserve: ' + formatTokenUnits(baseBal, CHAIN_138_POOL_BALANCE_DECIMALS, 3));
|
|
notes.push('Quote reserve: ' + formatTokenUnits(quoteBal, CHAIN_138_POOL_BALANCE_DECIMALS, 3));
|
|
}
|
|
return { category: category, poolPair: poolPair, poolType: poolType, address: poolAddress || '', status: status, notes: notes.join(' | ') };
|
|
}
|
|
|
|
rows.push(await buildLivePoolRow('Public Liquidity Pools', 'cUSDT / cUSDC', 'DODO PMM',
|
|
await rpcReadMappedPool(ctx.integration, ctx.compliantUSDT, ctx.compliantUSDC),
|
|
ctx.compliantUSDT, ctx.compliantUSDC, 'Derived from live DODOPMMIntegration state'));
|
|
|
|
rows.push(await buildLivePoolRow('Public Liquidity Pools', 'cUSDT / USDT (official mirror)', 'DODO PMM',
|
|
await rpcReadMappedPool(ctx.integration, ctx.compliantUSDT, ctx.officialUSDT),
|
|
ctx.compliantUSDT, ctx.officialUSDT, (await rpcCodeExists(ctx.officialUSDT)) ? ('Live quote token ' + shortenHash(ctx.officialUSDT)) : 'Quote-side USDT contract missing on Chain 138'));
|
|
|
|
rows.push(await buildLivePoolRow('Public Liquidity Pools', 'cUSDC / USDC (official mirror)', 'DODO PMM',
|
|
await rpcReadMappedPool(ctx.integration, ctx.compliantUSDC, ctx.officialUSDC),
|
|
ctx.compliantUSDC, ctx.officialUSDC, (await rpcCodeExists(ctx.officialUSDC)) ? ('Live quote token ' + shortenHash(ctx.officialUSDC)) : 'Quote-side USDC contract missing on Chain 138'));
|
|
|
|
var publicCusdtXau = await discoverPublicXauPool(ctx.compliantUSDT, xauAnchors, ctx.integration);
|
|
rows.push(await buildLivePoolRow('Public Liquidity Pools', 'cUSDT / XAU (' + publicCusdtXau.anchor.symbol + ')', 'DODO PMM',
|
|
publicCusdtXau.pool, ctx.compliantUSDT, publicCusdtXau.anchor.address,
|
|
safeAddress(publicCusdtXau.pool) ? 'Resolved via live integration pool mapping' : 'No live XAU public pool currently registered'));
|
|
|
|
var publicCusdcXau = await discoverPublicXauPool(ctx.compliantUSDC, xauAnchors, ctx.integration);
|
|
rows.push(await buildLivePoolRow('Public Liquidity Pools', 'cUSDC / XAU (' + publicCusdcXau.anchor.symbol + ')', 'DODO PMM',
|
|
publicCusdcXau.pool, ctx.compliantUSDC, publicCusdcXau.anchor.address,
|
|
safeAddress(publicCusdcXau.pool) ? 'Resolved via live integration pool mapping' : 'No live XAU public pool currently registered'));
|
|
|
|
var publicCeurtXau = await discoverPublicXauPool(CHAIN_138_CEURT_ADDRESS, xauAnchors, ctx.integration);
|
|
rows.push(await buildLivePoolRow('Public Liquidity Pools', 'cEURT / XAU (' + publicCeurtXau.anchor.symbol + ')', 'DODO PMM',
|
|
publicCeurtXau.pool, CHAIN_138_CEURT_ADDRESS, publicCeurtXau.anchor.address,
|
|
safeAddress(publicCeurtXau.pool) ? 'Resolved via live integration pool mapping' : 'No live XAU public pool currently registered'));
|
|
|
|
var privateCusdtXau = await discoverPrivateXauPool(ctx.compliantUSDT, xauAnchors, ctx.privateRegistry);
|
|
rows.push(await buildLivePoolRow('Private Stabilization Pools', 'cUSDT ↔ XAU (' + privateCusdtXau.anchor.symbol + ')', 'PrivatePoolRegistry',
|
|
privateCusdtXau.pool, ctx.compliantUSDT, privateCusdtXau.anchor.address,
|
|
safeAddress(privateCusdtXau.pool) ? ('Registered in live PrivatePoolRegistry ' + shortenHash(ctx.privateRegistry)) : 'No live private XAU pool currently registered'));
|
|
|
|
var privateCusdcXau = await discoverPrivateXauPool(ctx.compliantUSDC, xauAnchors, ctx.privateRegistry);
|
|
rows.push(await buildLivePoolRow('Private Stabilization Pools', 'cUSDC ↔ XAU (' + privateCusdcXau.anchor.symbol + ')', 'PrivatePoolRegistry',
|
|
privateCusdcXau.pool, ctx.compliantUSDC, privateCusdcXau.anchor.address,
|
|
safeAddress(privateCusdcXau.pool) ? ('Registered in live PrivatePoolRegistry ' + shortenHash(ctx.privateRegistry)) : 'No live private XAU pool currently registered'));
|
|
|
|
var privateCeurtXau = await discoverPrivateXauPool(CHAIN_138_CEURT_ADDRESS, xauAnchors, ctx.privateRegistry);
|
|
rows.push(await buildLivePoolRow('Private Stabilization Pools', 'cEURT ↔ XAU (' + privateCeurtXau.anchor.symbol + ')', 'PrivatePoolRegistry',
|
|
privateCeurtXau.pool, CHAIN_138_CEURT_ADDRESS, privateCeurtXau.anchor.address,
|
|
safeAddress(privateCeurtXau.pool) ? ('Registered in live PrivatePoolRegistry ' + shortenHash(ctx.privateRegistry)) : 'No live private XAU pool currently registered'));
|
|
|
|
rows.push({
|
|
category: 'Reserve Pools / Vault Backing',
|
|
poolPair: 'ReserveSystem',
|
|
poolType: 'Reserve',
|
|
address: RESERVE_SYSTEM_ADDRESS,
|
|
status: (await rpcCodeExists(RESERVE_SYSTEM_ADDRESS)) ? 'Deployed (live)' : 'Missing code',
|
|
notes: 'Live code check against Chain 138'
|
|
});
|
|
rows.push({
|
|
category: 'Reserve Pools / Vault Backing',
|
|
poolPair: 'ReserveTokenIntegration',
|
|
poolType: 'Reserve',
|
|
address: RESERVE_TOKEN_INTEGRATION_ADDRESS,
|
|
status: (await rpcCodeExists(RESERVE_TOKEN_INTEGRATION_ADDRESS)) ? 'Deployed (live)' : 'Missing code',
|
|
notes: 'Live code check against Chain 138'
|
|
});
|
|
rows.push({
|
|
category: 'Reserve Pools / Vault Backing',
|
|
poolPair: 'StablecoinReserveVault',
|
|
poolType: 'Reserve',
|
|
address: '',
|
|
status: 'External / Mainnet-side',
|
|
notes: 'Reserve vault lives on Mainnet by design; no local Chain 138 contract is expected here'
|
|
});
|
|
rows.push({
|
|
category: 'Reserve Pools / Vault Backing',
|
|
poolPair: 'Bridge_Vault',
|
|
poolType: 'Vault',
|
|
address: BRIDGE_VAULT_ADDRESS,
|
|
status: (await rpcCodeExists(BRIDGE_VAULT_ADDRESS)) ? 'Deployed (live)' : 'Missing code',
|
|
notes: 'Live code check against Chain 138'
|
|
});
|
|
rows.push({
|
|
category: 'Bridge Liquidity Pool',
|
|
poolPair: 'LiquidityPoolETH',
|
|
poolType: 'Bridge LP',
|
|
address: '',
|
|
status: 'External / Mainnet bridge LP',
|
|
notes: 'Trustless bridge liquidity pool is a Mainnet-side contract, not a local Chain 138 pool'
|
|
});
|
|
return { rows: rows, context: ctx };
|
|
}
|
|
function getActiveBridgeContractCount() {
|
|
return new Set([
|
|
'0x971cD9D156f193df8051E48043C476e53ECd4693',
|
|
'0xe0E93247376aa097dB308B92e6Ba36bA015535D0',
|
|
'0x2A0840e5117683b11682ac46f5CF5621E67269E3',
|
|
'0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03',
|
|
'0x8078a09637e47fa5ed34f626046ea2094a5cde5e',
|
|
'0xa780ef19a041745d353c9432f2a7f5a241335ffe',
|
|
'0x105f8a15b819948a89153505762444ee9f324684',
|
|
'0xdab0591e5e89295ffad75a71dcfc30c5625c4fa2'
|
|
].map(function(addr) { return String(addr || '').toLowerCase(); })).size;
|
|
}
|
|
function getAddressLabel(addr) { if (!addr) return ''; var lower = addr.toLowerCase(); if (KNOWN_ADDRESS_LABELS[lower]) return KNOWN_ADDRESS_LABELS[lower]; try { var j = localStorage.getItem('explorerAddressLabels'); if (!j) return ''; var m = JSON.parse(j); return m[lower] || ''; } catch(e){ return ''; } }
|
|
function formatAddressWithLabel(addr) { if (!addr) return ''; var label = getAddressLabel(addr); return label ? escapeHtml(label) + ' (' + escapeHtml(shortenHash(addr)) + ')' : escapeHtml(shortenHash(addr)); }
|
|
function copyToClipboard(val, msg) { if (!val) return; try { navigator.clipboard.writeText(String(val)); showToast(msg || 'Copied', 'success'); } catch(e) { showToast('Copy failed', 'error'); } }
|
|
function setAddressLabel(addr, label) { try { var j = localStorage.getItem('explorerAddressLabels') || '{}'; var m = JSON.parse(j); m[addr.toLowerCase()] = (label || '').trim(); localStorage.setItem('explorerAddressLabels', JSON.stringify(m)); return true; } catch(e){ return false; } }
|
|
function getWatchlist() { try { var j = localStorage.getItem('explorerWatchlist'); if (!j) return []; var a = JSON.parse(j); return Array.isArray(a) ? a : []; } catch(e){ return []; } }
|
|
function addToWatchlist(addr) { if (!addr || !/^0x[a-fA-F0-9]{40}$/i.test(addr)) return false; var a = getWatchlist(); var lower = addr.toLowerCase(); if (a.indexOf(lower) === -1) { a.push(lower); try { localStorage.setItem('explorerWatchlist', JSON.stringify(a)); return true; } catch(e){} } return false; }
|
|
function removeFromWatchlist(addr) { var a = getWatchlist().filter(function(x){ return x !== addr.toLowerCase(); }); try { localStorage.setItem('explorerWatchlist', JSON.stringify(a)); return true; } catch(e){ return false; } }
|
|
function isInWatchlist(addr) { return getWatchlist().indexOf((addr || '').toLowerCase()) !== -1; }
|
|
let currentView = 'home';
|
|
let _poolsRouteTreeRefreshTimer = null;
|
|
let currentDetailKey = '';
|
|
let latestPoolsSnapshot = null;
|
|
var _inNavHandler = false; // re-entrancy guard: prevents hashchange -> applyHashRoute -> stub from recursing
|
|
let provider = null;
|
|
let signer = null;
|
|
let userAddress = null;
|
|
|
|
// Tiered Architecture: Track and Authentication
|
|
let userTrack = 1; // Default to Track 1 (public)
|
|
let authToken = null;
|
|
|
|
// View switch helper: works even if rest of script fails. Do NOT set location.hash here (we use path-based URLs).
|
|
function switchToView(viewName) {
|
|
if (viewName !== 'pools' && viewName !== 'routes' && _poolsRouteTreeRefreshTimer) {
|
|
clearInterval(_poolsRouteTreeRefreshTimer);
|
|
_poolsRouteTreeRefreshTimer = null;
|
|
}
|
|
if (viewName !== 'blocks' && _blocksScrollAnimationId != null) {
|
|
cancelAnimationFrame(_blocksScrollAnimationId);
|
|
_blocksScrollAnimationId = null;
|
|
}
|
|
currentView = viewName;
|
|
var detailViews = ['blockDetail','transactionDetail','addressDetail','tokenDetail','nftDetail','watchlist','searchResults','tokens','addresses','pools','routes','liquidity','more'];
|
|
if (detailViews.indexOf(viewName) === -1) currentDetailKey = '';
|
|
var homeEl = document.getElementById('homeView');
|
|
if (homeEl) homeEl.style.display = viewName === 'home' ? 'block' : 'none';
|
|
document.querySelectorAll('.detail-view').forEach(function(el) { el.classList.remove('active'); });
|
|
var target = document.getElementById(viewName + 'View');
|
|
if (target) target.classList.add('active');
|
|
}
|
|
// Compatibility wrappers for nav buttons; re-entrancy guard prevents hashchange recursion.
|
|
window.showHome = function() { if (_inNavHandler) return; _inNavHandler = true; try { switchToView('home'); if (window._showHome) window._showHome(); } finally { _inNavHandler = false; } };
|
|
window.showBlocks = function() { if (_inNavHandler) return; _inNavHandler = true; try { switchToView('blocks'); if (window._showBlocks) window._showBlocks(); } finally { _inNavHandler = false; } };
|
|
window.showTransactions = function() { if (_inNavHandler) return; _inNavHandler = true; try { switchToView('transactions'); if (window._showTransactions) window._showTransactions(); } finally { _inNavHandler = false; } };
|
|
window.showAddresses = function() { if (_inNavHandler) return; _inNavHandler = true; try { switchToView('addresses'); if (window._showAddresses) window._showAddresses(); } finally { _inNavHandler = false; } };
|
|
window.showBridgeMonitoring = function() { if (_inNavHandler) return; _inNavHandler = true; try { switchToView('bridge'); if (window._showBridgeMonitoring) window._showBridgeMonitoring(); } finally { _inNavHandler = false; } };
|
|
window.showWETHUtilities = function() { if (_inNavHandler) return; _inNavHandler = true; try { switchToView('weth'); if (window._showWETHUtilities) window._showWETHUtilities(); } finally { _inNavHandler = false; } };
|
|
window.showWETHTab = function() {};
|
|
window.showWatchlist = function() { if (_inNavHandler) return; _inNavHandler = true; try { switchToView('watchlist'); if (window._renderWatchlist) window._renderWatchlist(); } finally { _inNavHandler = false; } };
|
|
function openPoolsView() { if (_inNavHandler) return; _inNavHandler = true; try { switchToView('pools'); if (typeof renderPoolsView === 'function') renderPoolsView(); } finally { _inNavHandler = false; } }
|
|
window.openPoolsView = openPoolsView;
|
|
// Back-compat alias for older menu wiring; prefer openPoolsView() and renderPoolsView().
|
|
window.showPools = openPoolsView;
|
|
window.showRoutes = function() { if (_inNavHandler) return; _inNavHandler = true; try { switchToView('routes'); if (typeof renderRoutesView === 'function') renderRoutesView(); } finally { _inNavHandler = false; } };
|
|
window.showLiquidityAccess = function() { if (_inNavHandler) return; _inNavHandler = true; try { switchToView('liquidity'); if (typeof renderLiquidityAccessView === 'function') renderLiquidityAccessView(); } finally { _inNavHandler = false; } };
|
|
window.showMore = function() { if (_inNavHandler) return; _inNavHandler = true; try { switchToView('more'); if (window._showMore) window._showMore(); } finally { _inNavHandler = false; } };
|
|
window.showTokensList = function() { if (_inNavHandler) return; _inNavHandler = true; try { switchToView('tokens'); if (window._loadTokensList) window._loadTokensList(); } finally { _inNavHandler = false; } };
|
|
window.showAnalytics = function() { if (_inNavHandler) return; _inNavHandler = true; try { switchToView('analytics'); if (window._showAnalytics) window._showAnalytics(); } finally { _inNavHandler = false; } };
|
|
window.showOperator = function() { if (_inNavHandler) return; _inNavHandler = true; try { switchToView('operator'); if (window._showOperator) window._showOperator(); } finally { _inNavHandler = false; } };
|
|
// Compatibility wrappers for detail views; defer to the next tick to avoid synchronous recursion.
|
|
window.showBlockDetail = function(n) { if (window._showBlockDetail) setTimeout(function() { window._showBlockDetail(n); }, 0); };
|
|
window.showTransactionDetail = function(h) { if (window._showTransactionDetail) setTimeout(function() { window._showTransactionDetail(h); }, 0); };
|
|
window.showAddressDetail = function(a) { if (window._showAddressDetail) setTimeout(function() { window._showAddressDetail(a); }, 0); };
|
|
window.toggleDarkMode = function() { document.body.classList.toggle('dark-theme'); var icon = document.getElementById('themeIcon'); if (icon) icon.className = document.body.classList.contains('dark-theme') ? 'fas fa-sun' : 'fas fa-moon'; try { localStorage.setItem('explorerTheme', document.body.classList.contains('dark-theme') ? 'dark' : 'light'); } catch (e) {} };
|
|
|
|
// Feature flags
|
|
const FEATURE_FLAGS = {
|
|
ADDRESS_FULL_DETAIL: { track: 2 },
|
|
TOKEN_BALANCES: { track: 2 },
|
|
TX_HISTORY: { track: 2 },
|
|
INTERNAL_TXS: { track: 2 },
|
|
ENHANCED_SEARCH: { track: 2 },
|
|
ANALYTICS_DASHBOARD: { track: 3 },
|
|
FLOW_TRACKING: { track: 3 },
|
|
BRIDGE_ANALYTICS: { track: 3 },
|
|
OPERATOR_PANEL: { track: 4 },
|
|
};
|
|
|
|
function hasAccess(requiredTrack) {
|
|
return userTrack >= requiredTrack;
|
|
}
|
|
|
|
function isFeatureEnabled(featureName) {
|
|
const feature = FEATURE_FLAGS[featureName];
|
|
if (!feature) return false;
|
|
return hasAccess(feature.track);
|
|
}
|
|
|
|
// Load feature flags from API
|
|
async function loadFeatureFlags() {
|
|
try {
|
|
const response = await fetch(EXPLORER_API_V1_BASE + '/features', {
|
|
headers: authToken ? { 'Authorization': `Bearer ${authToken}` } : {}
|
|
});
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
userTrack = data.track || 1;
|
|
updateUIForTrack();
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load feature flags:', error);
|
|
}
|
|
}
|
|
|
|
function updateUIForTrack() {
|
|
// Show/hide navigation items based on track
|
|
const analyticsNav = document.getElementById('analyticsNav');
|
|
const operatorNav = document.getElementById('operatorNav');
|
|
if (analyticsNav) analyticsNav.style.display = hasAccess(3) ? 'block' : 'none';
|
|
if (operatorNav) operatorNav.style.display = hasAccess(4) ? 'block' : 'none';
|
|
}
|
|
|
|
// Wallet authentication
|
|
async function connectWallet() {
|
|
if (typeof ethers === 'undefined') {
|
|
alert('Ethers.js not loaded. Please refresh the page.');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
if (!window.ethereum) {
|
|
alert('MetaMask not detected. Please install MetaMask.');
|
|
return;
|
|
}
|
|
|
|
const provider = new ethers.providers.Web3Provider(window.ethereum);
|
|
const accounts = await provider.send("eth_requestAccounts", []);
|
|
const address = accounts[0];
|
|
|
|
// Request nonce
|
|
const nonceResp = await fetch(EXPLORER_API_V1_BASE + '/auth/nonce', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ address })
|
|
});
|
|
const nonceData = await nonceResp.json();
|
|
|
|
// Sign message
|
|
const message = `Sign this message to authenticate with SolaceScanScout Explorer.\n\nNonce: ${nonceData.nonce}`;
|
|
const signer = provider.getSigner();
|
|
const signature = await signer.signMessage(message);
|
|
|
|
// Authenticate
|
|
const authResp = await fetch(EXPLORER_API_V1_BASE + '/auth/wallet', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ address, signature, nonce: nonceData.nonce })
|
|
});
|
|
|
|
if (authResp.ok) {
|
|
const authData = await authResp.json();
|
|
authToken = authData.token;
|
|
userTrack = authData.track;
|
|
userAddress = address;
|
|
localStorage.setItem('authToken', authToken);
|
|
localStorage.setItem('userAddress', userAddress);
|
|
updateUIForTrack();
|
|
const walletBtn = document.getElementById('walletConnectBtn');
|
|
const walletStatus = document.getElementById('walletStatus');
|
|
const walletAddress = document.getElementById('walletAddress');
|
|
if (walletBtn) walletBtn.style.display = 'none';
|
|
if (walletStatus) walletStatus.style.display = 'flex';
|
|
if (walletAddress) walletAddress.textContent = shortenHash(address);
|
|
await loadFeatureFlags();
|
|
showToast('Wallet connected successfully!', 'success');
|
|
} else {
|
|
const errorData = await authResp.json();
|
|
alert('Authentication failed: ' + (errorData.error?.message || 'Unknown error'));
|
|
}
|
|
} catch (error) {
|
|
var msg = (error && error.message) ? String(error.message) : '';
|
|
var friendly = (error && error.code === 4001) || /not been authorized|rejected|denied/i.test(msg)
|
|
? 'Connection was rejected. Please approve the MetaMask popup to connect.'
|
|
: ('Failed to connect wallet: ' + (msg || 'Unknown error'));
|
|
alert(friendly);
|
|
}
|
|
}
|
|
|
|
// Check for stored auth token on load
|
|
window.addEventListener('DOMContentLoaded', () => {
|
|
const storedToken = localStorage.getItem('authToken');
|
|
const storedAddress = localStorage.getItem('userAddress');
|
|
if (storedToken && storedAddress) {
|
|
authToken = storedToken;
|
|
userAddress = storedAddress;
|
|
const walletBtn = document.getElementById('walletConnectBtn');
|
|
const walletStatus = document.getElementById('walletStatus');
|
|
const walletAddress = document.getElementById('walletAddress');
|
|
if (walletBtn) walletBtn.style.display = 'none';
|
|
if (walletStatus) walletStatus.style.display = 'flex';
|
|
if (walletAddress) walletAddress.textContent = shortenHash(storedAddress);
|
|
loadFeatureFlags();
|
|
} else {
|
|
const walletBtn = document.getElementById('walletConnectBtn');
|
|
if (walletBtn) walletBtn.style.display = 'block';
|
|
}
|
|
});
|
|
|
|
// WETH Contract Addresses
|
|
const WETH9_ADDRESS = '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2';
|
|
// WETH10 address - will be checksummed when ethers is loaded
|
|
const WETH10_ADDRESS_RAW = '0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f';
|
|
let WETH10_ADDRESS = WETH10_ADDRESS_RAW; // Will be updated to checksummed version
|
|
|
|
// Data adapter functions to normalize Blockscout API responses
|
|
function normalizeBlock(blockscoutBlock) {
|
|
if (!blockscoutBlock) return null;
|
|
|
|
return {
|
|
number: blockscoutBlock.height || blockscoutBlock.number || parseInt(blockscoutBlock.block_number, 10),
|
|
hash: blockscoutBlock.hash || blockscoutBlock.block_hash,
|
|
parent_hash: blockscoutBlock.parent_hash || blockscoutBlock.parentHash,
|
|
timestamp: blockscoutBlock.timestamp,
|
|
miner: blockscoutBlock.miner?.hash || blockscoutBlock.miner || blockscoutBlock.miner_hash,
|
|
transaction_count: blockscoutBlock.transaction_count || blockscoutBlock.transactions_count || 0,
|
|
gas_used: blockscoutBlock.gas_used || '0',
|
|
gas_limit: blockscoutBlock.gas_limit || blockscoutBlock.gasLimit || '0',
|
|
size: blockscoutBlock.size || 0,
|
|
difficulty: blockscoutBlock.difficulty || '0',
|
|
base_fee_per_gas: blockscoutBlock.base_fee_per_gas,
|
|
burnt_fees: blockscoutBlock.burnt_fees || '0',
|
|
total_difficulty: blockscoutBlock.total_difficulty || '0',
|
|
nonce: blockscoutBlock.nonce || '0x0',
|
|
extra_data: blockscoutBlock.extra_data || '0x'
|
|
};
|
|
}
|
|
|
|
function normalizeTransaction(blockscoutTx) {
|
|
if (!blockscoutTx) return null;
|
|
|
|
// Map status: "ok" -> 1, "error" -> 0, others -> 0
|
|
let status = 0;
|
|
if (blockscoutTx.status === 'ok' || blockscoutTx.status === 'success') {
|
|
status = 1;
|
|
} else if (blockscoutTx.status === 1 || blockscoutTx.status === '1') {
|
|
status = 1;
|
|
}
|
|
|
|
return {
|
|
hash: blockscoutTx.hash || blockscoutTx.tx_hash,
|
|
from: blockscoutTx.from?.hash || blockscoutTx.from || blockscoutTx.from_address_hash || blockscoutTx.from_address,
|
|
to: blockscoutTx.to?.hash || blockscoutTx.to || blockscoutTx.to_address_hash || blockscoutTx.to_address,
|
|
value: blockscoutTx.value || '0',
|
|
block_number: blockscoutTx.block_number || blockscoutTx.block || null,
|
|
block_hash: blockscoutTx.block_hash || blockscoutTx.blockHash || null,
|
|
transaction_index: blockscoutTx.position || blockscoutTx.transaction_index || blockscoutTx.index || 0,
|
|
gas_price: blockscoutTx.gas_price || blockscoutTx.max_fee_per_gas || '0',
|
|
gas_used: blockscoutTx.gas_used || '0',
|
|
gas_limit: blockscoutTx.gas_limit || blockscoutTx.gas || '0',
|
|
nonce: blockscoutTx.nonce || '0',
|
|
status: status,
|
|
created_at: blockscoutTx.timestamp || blockscoutTx.created_at || blockscoutTx.block_timestamp,
|
|
input: blockscoutTx.input || blockscoutTx.raw_input || '0x',
|
|
max_fee_per_gas: blockscoutTx.max_fee_per_gas,
|
|
max_priority_fee_per_gas: blockscoutTx.max_priority_fee_per_gas,
|
|
priority_fee: blockscoutTx.priority_fee,
|
|
tx_burnt_fee: blockscoutTx.tx_burnt_fee || blockscoutTx.burnt_fees || '0',
|
|
type: blockscoutTx.type || 0,
|
|
confirmations: blockscoutTx.confirmations || 0,
|
|
contract_address: blockscoutTx.created_contract_address_hash || (blockscoutTx.contract_creation && (blockscoutTx.to?.hash || blockscoutTx.to_address_hash)) || null,
|
|
revert_reason: blockscoutTx.revert_reason || blockscoutTx.error || blockscoutTx.result || null,
|
|
decoded_input: blockscoutTx.decoded_input || null,
|
|
method_id: blockscoutTx.method_id || null
|
|
};
|
|
}
|
|
|
|
function normalizeAddress(blockscoutAddr) {
|
|
if (!blockscoutAddr || typeof blockscoutAddr !== 'object') return null;
|
|
var hash = blockscoutAddr.hash || blockscoutAddr.address || blockscoutAddr.address_hash;
|
|
if (!hash && blockscoutAddr.creator_address_hash === undefined) return null;
|
|
var creationTx = blockscoutAddr.creation_tx_hash || blockscoutAddr.creator_tx_hash || blockscoutAddr.creation_transaction_hash || null;
|
|
var firstSeen = blockscoutAddr.first_transaction_at || blockscoutAddr.first_seen_at || blockscoutAddr.first_tx_at || null;
|
|
var lastSeen = blockscoutAddr.last_transaction_at || blockscoutAddr.last_seen_at || blockscoutAddr.last_tx_at || null;
|
|
var txSent = blockscoutAddr.transactions_sent_count;
|
|
if (txSent == null) txSent = blockscoutAddr.tx_sent_count;
|
|
if (txSent == null) txSent = blockscoutAddr.transactions_count;
|
|
var txReceived = blockscoutAddr.transactions_received_count;
|
|
if (txReceived == null) txReceived = blockscoutAddr.tx_received_count;
|
|
if (txReceived == null) txReceived = 0;
|
|
return {
|
|
address: hash || null,
|
|
hash: hash || null,
|
|
balance: blockscoutAddr.balance || blockscoutAddr.coin_balance || blockscoutAddr.coin_balance_value || '0',
|
|
transaction_count: blockscoutAddr.transactions_count != null ? blockscoutAddr.transactions_count : (blockscoutAddr.transaction_count != null ? blockscoutAddr.transaction_count : (blockscoutAddr.tx_count != null ? blockscoutAddr.tx_count : 0)),
|
|
token_count: blockscoutAddr.token_count != null ? blockscoutAddr.token_count : 0,
|
|
is_contract: !!blockscoutAddr.is_contract,
|
|
is_verified: !!blockscoutAddr.is_verified,
|
|
tx_sent: txSent != null ? txSent : 0,
|
|
tx_received: txReceived != null ? txReceived : 0,
|
|
label: blockscoutAddr.name || blockscoutAddr.ens_domain_name || null,
|
|
name: blockscoutAddr.name || null,
|
|
ens_domain_name: blockscoutAddr.ens_domain_name || null,
|
|
creation_tx_hash: creationTx,
|
|
first_seen_at: firstSeen,
|
|
last_seen_at: lastSeen
|
|
};
|
|
}
|
|
|
|
function hexToDecimalString(value) {
|
|
if (value == null || value === '') return '0';
|
|
var stringValue = String(value);
|
|
if (!/^0x/i.test(stringValue)) return stringValue;
|
|
try {
|
|
return BigInt(stringValue).toString();
|
|
} catch (e) {
|
|
return '0';
|
|
}
|
|
}
|
|
|
|
function hexToNumber(value) {
|
|
if (value == null || value === '') return 0;
|
|
if (typeof value === 'number') return value;
|
|
try {
|
|
return Number(BigInt(String(value)));
|
|
} catch (e) {
|
|
return Number(value) || 0;
|
|
}
|
|
}
|
|
|
|
function rpcTimestampToIso(value) {
|
|
var timestampSeconds = hexToNumber(value);
|
|
if (!timestampSeconds) return new Date(0).toISOString();
|
|
return new Date(timestampSeconds * 1000).toISOString();
|
|
}
|
|
|
|
function normalizeRpcBlock(block) {
|
|
if (!block || !block.hash) return null;
|
|
return normalizeBlock({
|
|
number: hexToDecimalString(block.number),
|
|
hash: block.hash,
|
|
parent_hash: block.parentHash,
|
|
timestamp: rpcTimestampToIso(block.timestamp),
|
|
miner: block.miner,
|
|
transaction_count: Array.isArray(block.transactions) ? block.transactions.length : 0,
|
|
gas_used: hexToDecimalString(block.gasUsed),
|
|
gas_limit: hexToDecimalString(block.gasLimit),
|
|
size: hexToNumber(block.size),
|
|
difficulty: hexToDecimalString(block.difficulty),
|
|
base_fee_per_gas: block.baseFeePerGas ? hexToDecimalString(block.baseFeePerGas) : undefined,
|
|
nonce: block.nonce || '0x0'
|
|
});
|
|
}
|
|
|
|
function normalizeRpcTransaction(tx, receipt, block) {
|
|
if (!tx || !tx.hash) return null;
|
|
var blockTimestamp = block && block.timestamp ? rpcTimestampToIso(block.timestamp) : new Date(0).toISOString();
|
|
var txType = tx.type != null ? hexToNumber(tx.type) : 0;
|
|
var effectiveGasPrice = receipt && receipt.effectiveGasPrice ? receipt.effectiveGasPrice : tx.gasPrice;
|
|
return normalizeTransaction({
|
|
hash: tx.hash,
|
|
from: tx.from,
|
|
to: tx.to,
|
|
value: hexToDecimalString(tx.value),
|
|
block_number: tx.blockNumber ? hexToDecimalString(tx.blockNumber) : null,
|
|
block_hash: tx.blockHash || null,
|
|
transaction_index: tx.transactionIndex ? hexToNumber(tx.transactionIndex) : 0,
|
|
gas_price: effectiveGasPrice ? hexToDecimalString(effectiveGasPrice) : '0',
|
|
gas_used: receipt && receipt.gasUsed ? hexToDecimalString(receipt.gasUsed) : '0',
|
|
gas_limit: tx.gas ? hexToDecimalString(tx.gas) : '0',
|
|
nonce: tx.nonce != null ? String(hexToNumber(tx.nonce)) : '0',
|
|
status: receipt && receipt.status != null ? hexToNumber(receipt.status) : 0,
|
|
created_at: blockTimestamp,
|
|
input: tx.input || '0x',
|
|
max_fee_per_gas: tx.maxFeePerGas ? hexToDecimalString(tx.maxFeePerGas) : undefined,
|
|
max_priority_fee_per_gas: tx.maxPriorityFeePerGas ? hexToDecimalString(tx.maxPriorityFeePerGas) : undefined,
|
|
type: txType,
|
|
contract_address: receipt && receipt.contractAddress ? receipt.contractAddress : null
|
|
});
|
|
}
|
|
|
|
async function fetchChain138BlocksPage(page, pageSize) {
|
|
try {
|
|
var response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/blocks?page=${page}&page_size=${pageSize}`);
|
|
if (response && response.items) {
|
|
return response.items.map(normalizeBlock).filter(function(block) { return block !== null; });
|
|
}
|
|
} catch (error) {
|
|
console.warn('Falling back to RPC blocks list:', error.message || error);
|
|
}
|
|
var blocks = [];
|
|
var latestBlockHex = await rpcCall('eth_blockNumber', []);
|
|
var latestBlock = hexToNumber(latestBlockHex);
|
|
var startIndex = Math.max(0, latestBlock - ((page - 1) * pageSize));
|
|
for (var i = 0; i < pageSize && startIndex - i >= 0; i++) {
|
|
var block = await rpcCall('eth_getBlockByNumber', ['0x' + (startIndex - i).toString(16), false]).catch(function() { return null; });
|
|
var normalized = normalizeRpcBlock(block);
|
|
if (normalized) blocks.push(normalized);
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
async function fetchChain138TransactionsPage(page, pageSize) {
|
|
try {
|
|
var response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/transactions?page=${page}&page_size=${pageSize}`);
|
|
if (response && response.items) {
|
|
return response.items.map(normalizeTransaction).filter(function(tx) { return tx !== null; });
|
|
}
|
|
} catch (error) {
|
|
console.warn('Falling back to RPC transactions list:', error.message || error);
|
|
}
|
|
var transactions = [];
|
|
var latestBlockHex = await rpcCall('eth_blockNumber', []);
|
|
var latestBlock = hexToNumber(latestBlockHex);
|
|
var blockCursor = Math.max(0, latestBlock - ((page - 1) * 5));
|
|
while (blockCursor >= 0 && transactions.length < pageSize) {
|
|
var block = await rpcCall('eth_getBlockByNumber', ['0x' + blockCursor.toString(16), true]).catch(function() { return null; });
|
|
if (block && Array.isArray(block.transactions)) {
|
|
var blockIsoTimestamp = rpcTimestampToIso(block.timestamp);
|
|
block.transactions.forEach(function(tx) {
|
|
if (transactions.length >= pageSize) return;
|
|
var normalized = normalizeTransaction({
|
|
hash: tx.hash,
|
|
from: tx.from,
|
|
to: tx.to,
|
|
value: hexToDecimalString(tx.value),
|
|
block_number: tx.blockNumber ? hexToDecimalString(tx.blockNumber) : hexToDecimalString(block.number),
|
|
block_hash: tx.blockHash || block.hash,
|
|
gas_price: tx.gasPrice ? hexToDecimalString(tx.gasPrice) : (tx.maxFeePerGas ? hexToDecimalString(tx.maxFeePerGas) : '0'),
|
|
gas_limit: tx.gas ? hexToDecimalString(tx.gas) : '0',
|
|
nonce: tx.nonce != null ? String(hexToNumber(tx.nonce)) : '0',
|
|
created_at: blockIsoTimestamp,
|
|
input: tx.input || '0x',
|
|
max_fee_per_gas: tx.maxFeePerGas ? hexToDecimalString(tx.maxFeePerGas) : undefined,
|
|
max_priority_fee_per_gas: tx.maxPriorityFeePerGas ? hexToDecimalString(tx.maxPriorityFeePerGas) : undefined,
|
|
type: tx.type != null ? hexToNumber(tx.type) : 0
|
|
});
|
|
if (normalized) transactions.push(normalized);
|
|
});
|
|
}
|
|
blockCursor -= 1;
|
|
}
|
|
return transactions;
|
|
}
|
|
|
|
async function fetchChain138BlockDetail(blockNumber) {
|
|
try {
|
|
var response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/blocks/${blockNumber}`);
|
|
return {
|
|
block: normalizeBlock(response),
|
|
rawBlockResponse: response
|
|
};
|
|
} catch (error) {
|
|
console.warn('Falling back to RPC block detail:', error.message || error);
|
|
}
|
|
var rpcBlock = await rpcCall('eth_getBlockByNumber', ['0x' + Number(blockNumber).toString(16), true]);
|
|
return {
|
|
block: normalizeRpcBlock(rpcBlock),
|
|
rawBlockResponse: null
|
|
};
|
|
}
|
|
|
|
async function fetchChain138TransactionDetail(txHash) {
|
|
try {
|
|
var response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/transactions/${txHash}`);
|
|
return {
|
|
transaction: normalizeTransaction(response),
|
|
rawTransaction: response
|
|
};
|
|
} catch (error) {
|
|
console.warn('Falling back to RPC transaction detail:', error.message || error);
|
|
}
|
|
var rpcTx = await rpcCall('eth_getTransactionByHash', [txHash]);
|
|
if (!rpcTx) {
|
|
return { transaction: null, rawTransaction: null };
|
|
}
|
|
var receipt = await rpcCall('eth_getTransactionReceipt', [txHash]).catch(function() { return null; });
|
|
var block = rpcTx.blockNumber ? await rpcCall('eth_getBlockByNumber', [rpcTx.blockNumber, false]).catch(function() { return null; }) : null;
|
|
return {
|
|
transaction: normalizeRpcTransaction(rpcTx, receipt, block),
|
|
rawTransaction: null
|
|
};
|
|
}
|
|
|
|
// Skeleton loader function
|
|
function createSkeletonLoader(type) {
|
|
switch(type) {
|
|
case 'stats':
|
|
return `
|
|
<div class="stats-grid">
|
|
<div class="stat-card">
|
|
<div class="skeleton skeleton-title"></div>
|
|
<div class="skeleton skeleton-text" style="width: 80%;"></div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="skeleton skeleton-title"></div>
|
|
<div class="skeleton skeleton-text" style="width: 80%;"></div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="skeleton skeleton-title"></div>
|
|
<div class="skeleton skeleton-text" style="width: 80%;"></div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="skeleton skeleton-title"></div>
|
|
<div class="skeleton skeleton-text" style="width: 80%;"></div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
case 'table':
|
|
return `
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th><div class="skeleton skeleton-text"></div></th>
|
|
<th><div class="skeleton skeleton-text"></div></th>
|
|
<th><div class="skeleton skeleton-text"></div></th>
|
|
<th><div class="skeleton skeleton-text"></div></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${Array(5).fill(0).map(() => `
|
|
<tr>
|
|
<td><div class="skeleton skeleton-text"></div></td>
|
|
<td><div class="skeleton skeleton-text"></div></td>
|
|
<td><div class="skeleton skeleton-text"></div></td>
|
|
<td><div class="skeleton skeleton-text"></div></td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
`;
|
|
case 'detail':
|
|
return `
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<div class="skeleton skeleton-title" style="width: 40%;"></div>
|
|
</div>
|
|
<div class="card-body">
|
|
${Array(8).fill(0).map(() => `
|
|
<div class="info-row">
|
|
<div class="info-label">
|
|
<div class="skeleton skeleton-text" style="width: 120px;"></div>
|
|
</div>
|
|
<div class="info-value">
|
|
<div class="skeleton skeleton-text" style="width: 200px;"></div>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
</div>
|
|
`;
|
|
default:
|
|
return '<div class="loading"><i class="fas fa-spinner fa-spin"></i> Loading...</div>';
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Parse amount from WETH wrap/unwrap field. Label is "Amount (ETH)".
|
|
* - If input looks like raw wei (integer string, 18+ digits, no decimal), use as wei.
|
|
* - Otherwise treat as ETH and convert with parseEther (e.g. "100" -> 100 ETH).
|
|
* So pasting 100000000000000000000 wraps 100 ETH; typing 100 also wraps 100 ETH.
|
|
*/
|
|
function parseWETHAmount(inputStr) {
|
|
var s = (inputStr && String(inputStr).trim()) || '';
|
|
if (!s) return null;
|
|
try {
|
|
if (/^\d+$/.test(s) && s.length >= 18) {
|
|
return ethers.BigNumber.from(s);
|
|
}
|
|
return ethers.utils.parseEther(s);
|
|
} catch (e) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// WETH ABI (Standard ERC-20 + WETH functions)
|
|
const WETH_ABI = [
|
|
"function deposit() payable",
|
|
"function withdraw(uint256 wad)",
|
|
"function balanceOf(address account) view returns (uint256)",
|
|
"function transfer(address to, uint256 amount) returns (bool)",
|
|
"function approve(address spender, uint256 amount) returns (bool)",
|
|
"function allowance(address owner, address spender) view returns (uint256)",
|
|
"function totalSupply() view returns (uint256)",
|
|
"function name() view returns (string)",
|
|
"function symbol() view returns (string)",
|
|
"function decimals() view returns (uint8)",
|
|
"event Deposit(address indexed dst, uint256 wad)",
|
|
"event Withdrawal(address indexed src, uint256 wad)"
|
|
];
|
|
|
|
// Helper function to check if ethers is loaded
|
|
function ensureEthers() {
|
|
// Check immediately - ethers might already be loaded
|
|
if (typeof ethers !== 'undefined') {
|
|
window.ethersReady = true;
|
|
return Promise.resolve(true);
|
|
}
|
|
|
|
// Wait for ethers to load if it's still loading
|
|
return new Promise((resolve, reject) => {
|
|
let resolved = false;
|
|
|
|
// Check immediately first (double check)
|
|
if (typeof ethers !== 'undefined') {
|
|
window.ethersReady = true;
|
|
resolve(true);
|
|
return;
|
|
}
|
|
|
|
// Wait for ethersReady event
|
|
const timeout = setTimeout(() => {
|
|
if (!resolved) {
|
|
resolved = true;
|
|
// Final check before rejecting
|
|
if (typeof ethers !== 'undefined') {
|
|
window.ethersReady = true;
|
|
resolve(true);
|
|
} else {
|
|
console.error('Ethers library failed to load after 20 seconds');
|
|
reject(new Error('Ethers library failed to load. Please refresh the page.'));
|
|
}
|
|
}
|
|
}, 20000); // 20 second timeout
|
|
|
|
const checkInterval = setInterval(() => {
|
|
if (typeof ethers !== 'undefined' && !resolved) {
|
|
resolved = true;
|
|
clearInterval(checkInterval);
|
|
clearTimeout(timeout);
|
|
window.ethersReady = true;
|
|
console.log('✅ Ethers detected via polling');
|
|
resolve(true);
|
|
}
|
|
}, 100);
|
|
|
|
// Listen for ethersReady event
|
|
const onReady = function() {
|
|
if (!resolved) {
|
|
resolved = true;
|
|
clearInterval(checkInterval);
|
|
clearTimeout(timeout);
|
|
window.removeEventListener('ethersReady', onReady);
|
|
if (typeof ethers !== 'undefined') {
|
|
window.ethersReady = true;
|
|
console.log('✅ Ethers ready via event');
|
|
resolve(true);
|
|
} else {
|
|
console.error('ethersReady event fired but ethers is still undefined');
|
|
reject(new Error('Ethers library is not loaded. Please refresh the page.'));
|
|
}
|
|
}
|
|
};
|
|
|
|
window.addEventListener('ethersReady', onReady, { once: true });
|
|
});
|
|
}
|
|
|
|
// Helper function to get API URL based on chain ID
|
|
function getAPIUrl(endpoint) {
|
|
// For ChainID 138, use Blockscout API
|
|
if (CHAIN_ID === 138) {
|
|
return `${BLOCKSCOUT_API}${endpoint}`;
|
|
}
|
|
// For other networks, use v2 Etherscan/Blockscan APIs
|
|
return `${API_BASE}/v2${endpoint}`;
|
|
}
|
|
|
|
// Initialize - only run once
|
|
let initialized = false;
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
if (initialized) {
|
|
console.warn('Initialization already completed, skipping...');
|
|
return;
|
|
}
|
|
initialized = true;
|
|
|
|
applyStoredTheme();
|
|
var localeSel = document.getElementById('localeSelect'); if (localeSel) localeSel.value = currentLocale;
|
|
if (typeof applyI18n === 'function') applyI18n();
|
|
var initialRoute = (window.location.pathname || '/').replace(/^\//, '').replace(/\/$/, '').replace(/^index\.html$/i, '');
|
|
applyHashRoute();
|
|
window.addEventListener('popstate', function() { applyHashRoute(); });
|
|
window.addEventListener('hashchange', function() { applyHashRoute(); });
|
|
window.addEventListener('load', function() { applyHashRoute(); });
|
|
var shouldLoadHomeData = !initialRoute || initialRoute === 'home';
|
|
if (shouldLoadHomeData) {
|
|
console.log('Loading stats, blocks, and transactions...');
|
|
loadStats();
|
|
loadLatestBlocks();
|
|
loadLatestTransactions();
|
|
startTransactionUpdates();
|
|
} else {
|
|
setTimeout(function() { applyHashRoute(); }, 0);
|
|
}
|
|
|
|
// Ethers is only needed for MetaMask/WETH; don't block feeds on it
|
|
try {
|
|
await ensureEthers();
|
|
console.log('Ethers ready.');
|
|
} catch (error) {
|
|
console.warn('Ethers not ready, continuing without MetaMask features:', error);
|
|
}
|
|
|
|
setTimeout(() => {
|
|
if (typeof ethers !== 'undefined' && typeof window.ethereum !== 'undefined') {
|
|
checkMetaMaskConnection();
|
|
}
|
|
}, 500);
|
|
});
|
|
|
|
// MetaMask Connection
|
|
let checkingMetaMask = false;
|
|
async function checkMetaMaskConnection() {
|
|
// Prevent multiple simultaneous checks
|
|
if (checkingMetaMask) {
|
|
console.log('checkMetaMaskConnection already in progress, skipping...');
|
|
return;
|
|
}
|
|
checkingMetaMask = true;
|
|
|
|
try {
|
|
// Ensure ethers is loaded before checking MetaMask
|
|
if (typeof ethers === 'undefined') {
|
|
try {
|
|
await ensureEthers();
|
|
// Wait a bit more to ensure ethers is fully initialized
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
} catch (error) {
|
|
console.warn('Ethers not available, skipping MetaMask check:', error);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Double-check ethers is available
|
|
if (typeof ethers === 'undefined') {
|
|
console.warn('Ethers still not available after ensureEthers(), skipping MetaMask check');
|
|
return;
|
|
}
|
|
|
|
if (typeof window.ethereum !== 'undefined') {
|
|
try {
|
|
const accounts = await window.ethereum.request({ method: 'eth_accounts' });
|
|
if (accounts.length > 0) {
|
|
await connectMetaMask();
|
|
}
|
|
} catch (_) {
|
|
// User has not authorized the site yet; skip auto-connect silently
|
|
}
|
|
}
|
|
} finally {
|
|
checkingMetaMask = false;
|
|
}
|
|
}
|
|
|
|
var ERC20_META_ABI = ['function symbol() view returns (string)', 'function name() view returns (string)', 'function decimals() view returns (uint8)'];
|
|
var SYMBOL_SELECTOR = '0x95d89b41';
|
|
var NAME_SELECTOR = '0x06fdde03';
|
|
var DECIMALS_SELECTOR = '0x313ce567';
|
|
function decodeBytes32OrString(hex) {
|
|
if (!hex || typeof hex !== 'string' || hex.length < 66) return '';
|
|
var data = hex.slice(2);
|
|
if (data.length >= 64) {
|
|
var offset = parseInt(data.slice(0, 64), 16);
|
|
var len = parseInt(data.slice(64, 128), 16);
|
|
if (offset === 32 && len > 0 && data.length >= 128 + len * 2) {
|
|
return ethers.utils.toUtf8String('0x' + data.slice(128, 128 + len * 2)).replace(/\0+$/g, '');
|
|
}
|
|
var fixed = '0x' + data.slice(0, 64);
|
|
try {
|
|
return ethers.utils.parseBytes32String(fixed).replace(/\0+$/g, '');
|
|
} catch (_) {
|
|
return ethers.utils.toUtf8String(fixed).replace(/\0+$/g, '');
|
|
}
|
|
}
|
|
return '';
|
|
}
|
|
async function fetchTokenMetadataFromChain(address) {
|
|
if (typeof window.ethereum === 'undefined' || typeof ethers === 'undefined') return null;
|
|
var prov = new ethers.providers.Web3Provider(window.ethereum);
|
|
try {
|
|
var contract = new ethers.Contract(address, ERC20_META_ABI, prov);
|
|
var sym = await contract.symbol();
|
|
var nam = await contract.name();
|
|
var dec = await contract.decimals();
|
|
var decimalsNum = (typeof dec === 'number') ? dec : (dec && dec.toNumber ? dec.toNumber() : 18);
|
|
var symbolStr = (sym != null && sym !== undefined) ? String(sym) : '';
|
|
var nameStr = (nam != null && nam !== undefined) ? String(nam) : '';
|
|
return { symbol: symbolStr, name: nameStr, decimals: decimalsNum };
|
|
} catch (e) {
|
|
try {
|
|
var outSymbol = await window.ethereum.request({ method: 'eth_call', params: [{ to: address, data: SYMBOL_SELECTOR }] });
|
|
var outName = await window.ethereum.request({ method: 'eth_call', params: [{ to: address, data: NAME_SELECTOR }] });
|
|
var outDecimals = await window.ethereum.request({ method: 'eth_call', params: [{ to: address, data: DECIMALS_SELECTOR }] });
|
|
if (outSymbol && outSymbol !== '0x') {
|
|
var symbolStr = decodeBytes32OrString(outSymbol);
|
|
var nameStr = outName && outName !== '0x' ? decodeBytes32OrString(outName) : '';
|
|
var decimalsNum = 18;
|
|
if (outDecimals && outDecimals.length >= 66) {
|
|
decimalsNum = parseInt(outDecimals.slice(2, 66), 16);
|
|
if (isNaN(decimalsNum)) decimalsNum = 18;
|
|
}
|
|
return { symbol: symbolStr, name: nameStr, decimals: decimalsNum };
|
|
}
|
|
} catch (_) {}
|
|
return null;
|
|
}
|
|
}
|
|
|
|
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');
|
|
return;
|
|
}
|
|
if (typeof window.ethereum === 'undefined') {
|
|
if (typeof showToast === 'function') showToast('No wallet detected. Install MetaMask or another Web3 wallet.', 'error');
|
|
return;
|
|
}
|
|
try {
|
|
var meta = await fetchTokenMetadataFromChain(address);
|
|
if (!meta) {
|
|
if (typeof showToast === 'function') showToast('Could not read token from chain. Switch to the correct network and try again.', 'error');
|
|
return;
|
|
}
|
|
var useSymbol = (meta.symbol !== undefined && meta.symbol !== null) ? meta.symbol : 'TOKEN';
|
|
var useName = (meta.name !== undefined && meta.name !== null) ? meta.name : (name || '');
|
|
var useDecimals = (typeof meta.decimals === 'number') ? meta.decimals : (typeof decimals === 'number' ? decimals : 18);
|
|
if (useSymbol === '' || (typeof useSymbol === 'string' && useSymbol.trim() === '')) {
|
|
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 added = await window.ethereum.request({
|
|
method: 'wallet_watchAsset',
|
|
params: {
|
|
type: 'ERC20',
|
|
options: {
|
|
address: address,
|
|
symbol: (useSymbol !== undefined && useSymbol !== null) ? useSymbol : 'TOKEN',
|
|
decimals: useDecimals,
|
|
name: useName || undefined,
|
|
image: undefined
|
|
}
|
|
}
|
|
});
|
|
if (typeof showToast === 'function') {
|
|
var displaySym = useSymbol || symbol || 'Token';
|
|
showToast(added ? (displaySym ? displaySym + ' added to wallet' : 'Token added to wallet') : 'Add token was cancelled', added ? 'success' : 'info');
|
|
}
|
|
} catch (e) {
|
|
var msg = (e && e.message) ? String(e.message) : '';
|
|
var friendly = (e && e.code === 4001) || /not been authorized|rejected|denied/i.test(msg)
|
|
? 'Please approve the MetaMask popup to add the token.'
|
|
: (msg || 'Could not add token to wallet.');
|
|
if (typeof showToast === 'function') showToast(friendly, 'error');
|
|
}
|
|
}
|
|
window.addTokenToWallet = addTokenToWallet;
|
|
|
|
let connectingMetaMask = false;
|
|
async function connectMetaMask() {
|
|
// Prevent multiple simultaneous connections
|
|
if (connectingMetaMask) {
|
|
console.log('connectMetaMask already in progress, skipping...');
|
|
return;
|
|
}
|
|
connectingMetaMask = true;
|
|
|
|
try {
|
|
if (typeof window.ethereum === 'undefined') {
|
|
alert('MetaMask is not installed! Please install MetaMask to use WETH utilities.');
|
|
return;
|
|
}
|
|
|
|
// Wait for ethers to be loaded
|
|
if (typeof ethers === 'undefined') {
|
|
try {
|
|
await ensureEthers();
|
|
// Wait a bit more to ensure ethers is fully initialized
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
} catch (error) {
|
|
alert('Ethers library is not loaded. Please refresh the page and try again.');
|
|
console.error('ethers loading error:', error);
|
|
return;
|
|
}
|
|
}
|
|
|
|
// Double-check ethers is available
|
|
if (typeof ethers === 'undefined') {
|
|
alert('Ethers library is not loaded. Please refresh the page and try again.');
|
|
console.error('Ethers still not available after ensureEthers()');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// Request account access
|
|
const accounts = await window.ethereum.request({ method: 'eth_requestAccounts' });
|
|
userAddress = accounts[0];
|
|
|
|
// Connect to Chain 138
|
|
await switchToChain138();
|
|
|
|
// Setup provider and signer
|
|
provider = new ethers.providers.Web3Provider(window.ethereum);
|
|
signer = provider.getSigner();
|
|
|
|
// Update UI
|
|
const statusEl = document.getElementById('metamaskStatus');
|
|
statusEl.className = 'metamask-status connected';
|
|
statusEl.innerHTML = `
|
|
<i class="fas fa-check-circle"></i>
|
|
<span>Connected: ${escapeHtml(shortenHash(userAddress))}</span>
|
|
<button class="btn btn-warning" onclick="disconnectMetaMask()" style="margin-left: auto;" aria-label="Disconnect MetaMask wallet">Disconnect</button>
|
|
`;
|
|
|
|
// Enable buttons
|
|
document.getElementById('weth9WrapBtn').disabled = false;
|
|
document.getElementById('weth9UnwrapBtn').disabled = false;
|
|
document.getElementById('weth10WrapBtn').disabled = false;
|
|
document.getElementById('weth10UnwrapBtn').disabled = false;
|
|
|
|
// Load balances
|
|
await refreshWETHBalances();
|
|
|
|
// Listen for account changes
|
|
window.ethereum.on('accountsChanged', (accounts) => {
|
|
if (accounts.length === 0) {
|
|
disconnectMetaMask();
|
|
} else {
|
|
connectMetaMask();
|
|
}
|
|
});
|
|
|
|
// Listen for chain changes
|
|
window.ethereum.on('chainChanged', () => {
|
|
switchToChain138();
|
|
});
|
|
} catch (error) {
|
|
var errMsg = (error && error.message) ? String(error.message) : '';
|
|
var friendly = (error && error.code === 4001) || /not been authorized|rejected|denied/i.test(errMsg)
|
|
? 'Connection was rejected. Click Connect Wallet and approve access when MetaMask asks.'
|
|
: (errMsg.includes('ethers is not defined') || typeof ethers === 'undefined')
|
|
? 'Ethers library failed to load. Please refresh the page.'
|
|
: ('Failed to connect MetaMask: ' + (errMsg || 'Unknown error'));
|
|
alert(friendly);
|
|
}
|
|
} finally {
|
|
connectingMetaMask = false;
|
|
}
|
|
}
|
|
|
|
async function switchToChain138() {
|
|
const chainId = '0x8A'; // 138 in hex
|
|
try {
|
|
await window.ethereum.request({
|
|
method: 'wallet_switchEthereumChain',
|
|
params: [{ chainId }],
|
|
});
|
|
} catch (switchError) {
|
|
// If chain doesn't exist, add it
|
|
if (switchError.code === 4902) {
|
|
try {
|
|
await window.ethereum.request({
|
|
method: 'wallet_addEthereumChain',
|
|
params: [{
|
|
chainId,
|
|
chainName: 'Chain 138',
|
|
nativeCurrency: {
|
|
name: 'ETH',
|
|
symbol: 'ETH',
|
|
decimals: 18
|
|
},
|
|
rpcUrls: RPC_URLS.length > 0 ? RPC_URLS : [RPC_URL],
|
|
blockExplorerUrls: [window.location.origin || 'https://explorer.d-bis.org']
|
|
}],
|
|
});
|
|
} catch (addError) {
|
|
throw addError;
|
|
}
|
|
} else {
|
|
throw switchError;
|
|
}
|
|
}
|
|
}
|
|
|
|
function disconnectMetaMask() {
|
|
provider = null;
|
|
signer = null;
|
|
userAddress = null;
|
|
|
|
const statusEl = document.getElementById('metamaskStatus');
|
|
statusEl.className = 'metamask-status disconnected';
|
|
statusEl.innerHTML = `
|
|
<i class="fas fa-wallet"></i>
|
|
<span>MetaMask not connected</span>
|
|
<button class="btn btn-success" onclick="connectMetaMask()" style="margin-left: auto;" aria-label="Connect MetaMask wallet">Connect MetaMask</button>
|
|
`;
|
|
|
|
document.getElementById('weth9WrapBtn').disabled = true;
|
|
document.getElementById('weth9UnwrapBtn').disabled = true;
|
|
document.getElementById('weth10WrapBtn').disabled = true;
|
|
document.getElementById('weth10UnwrapBtn').disabled = true;
|
|
}
|
|
|
|
async function refreshWETHBalances() {
|
|
if (!userAddress) return;
|
|
|
|
try {
|
|
await ensureEthers();
|
|
|
|
// Checksum addresses when ethers is available
|
|
if (typeof ethers !== 'undefined' && ethers.utils) {
|
|
try {
|
|
// Convert to lowercase first, then checksum
|
|
const lowerAddress = WETH10_ADDRESS_RAW.toLowerCase();
|
|
WETH10_ADDRESS = ethers.utils.getAddress(lowerAddress);
|
|
} catch (e) {
|
|
console.warn('Could not checksum WETH10 address:', e);
|
|
// Fallback to lowercase version
|
|
WETH10_ADDRESS = WETH10_ADDRESS_RAW.toLowerCase();
|
|
}
|
|
} else {
|
|
// Fallback to lowercase if ethers not available
|
|
WETH10_ADDRESS = WETH10_ADDRESS_RAW.toLowerCase();
|
|
}
|
|
|
|
// Get ETH balance
|
|
const ethBalance = await provider.getBalance(userAddress);
|
|
const ethBalanceFormatted = formatEther(ethBalance);
|
|
|
|
// Get WETH9 balance
|
|
const weth9Contract = new ethers.Contract(WETH9_ADDRESS, WETH_ABI, provider);
|
|
const weth9Balance = await weth9Contract.balanceOf(userAddress);
|
|
const weth9BalanceFormatted = formatEther(weth9Balance);
|
|
|
|
// Get WETH10 balance - use checksummed address
|
|
const weth10Contract = new ethers.Contract(WETH10_ADDRESS, WETH_ABI, provider);
|
|
const weth10Balance = await weth10Contract.balanceOf(userAddress);
|
|
const weth10BalanceFormatted = formatEther(weth10Balance);
|
|
|
|
// Update UI
|
|
document.getElementById('weth9EthBalance').textContent = ethBalanceFormatted + ' ETH';
|
|
document.getElementById('weth9TokenBalance').textContent = weth9BalanceFormatted + ' WETH9';
|
|
document.getElementById('weth10EthBalance').textContent = ethBalanceFormatted + ' ETH';
|
|
document.getElementById('weth10TokenBalance').textContent = weth10BalanceFormatted + ' WETH10';
|
|
} catch (error) {
|
|
console.error('Error refreshing balances:', error);
|
|
}
|
|
}
|
|
|
|
function wrapUnwrapErrorMessage(op, error) {
|
|
if (error && (error.code === 4001 || error.code === 'ACTION_REJECTED' || (error.message && /user rejected|user denied/i.test(error.message)))) return 'Transaction cancelled.';
|
|
if (error && error.reason) return error.reason;
|
|
return (error && error.message) ? error.message : 'Unknown error';
|
|
}
|
|
function setMaxWETH9(type) {
|
|
if (type === 'wrap') {
|
|
const ethBalance = document.getElementById('weth9EthBalance').textContent.replace(' ETH', '');
|
|
document.getElementById('weth9WrapAmount').value = parseFloat(ethBalance).toFixed(6);
|
|
} else {
|
|
const wethBalance = document.getElementById('weth9TokenBalance').textContent.replace(' WETH9', '');
|
|
document.getElementById('weth9UnwrapAmount').value = parseFloat(wethBalance).toFixed(6);
|
|
}
|
|
}
|
|
|
|
function setMaxWETH10(type) {
|
|
if (type === 'wrap') {
|
|
const ethBalance = document.getElementById('weth10EthBalance').textContent.replace(' ETH', '');
|
|
document.getElementById('weth10WrapAmount').value = parseFloat(ethBalance).toFixed(6);
|
|
} else {
|
|
const wethBalance = document.getElementById('weth10TokenBalance').textContent.replace(' WETH10', '');
|
|
document.getElementById('weth10UnwrapAmount').value = parseFloat(wethBalance).toFixed(6);
|
|
}
|
|
}
|
|
|
|
async function wrapWETH9() {
|
|
const amount = document.getElementById('weth9WrapAmount').value;
|
|
if (!amount || parseFloat(amount) <= 0) {
|
|
alert('Please enter a valid amount');
|
|
return;
|
|
}
|
|
|
|
if (!signer) {
|
|
alert('Please connect MetaMask first');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await ensureEthers();
|
|
const amountWei = parseWETHAmount(amount);
|
|
if (!amountWei || amountWei.isZero()) {
|
|
alert('Please enter a valid amount in ETH or wei (e.g. 100 or 100000000000000000000).');
|
|
return;
|
|
}
|
|
const ethBalance = await provider.getBalance(userAddress);
|
|
if (ethBalance.lt(amountWei)) {
|
|
alert('Insufficient ETH balance. You have ' + formatEther(ethBalance) + ' ETH.');
|
|
return;
|
|
}
|
|
const weth9Contract = new ethers.Contract(WETH9_ADDRESS, WETH_ABI, signer);
|
|
try {
|
|
await weth9Contract.callStatic.deposit({ value: amountWei });
|
|
} catch (e) {
|
|
alert('Simulation failed: ' + (e.reason || e.message || 'Unknown error'));
|
|
return;
|
|
}
|
|
const btn = document.getElementById('weth9WrapBtn');
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Processing...';
|
|
|
|
const tx = await weth9Contract.deposit({ value: amountWei });
|
|
const receipt = await tx.wait();
|
|
|
|
btn.innerHTML = '<i class="fas fa-check"></i> Success!';
|
|
document.getElementById('weth9WrapAmount').value = '';
|
|
await refreshWETHBalances();
|
|
|
|
setTimeout(() => {
|
|
btn.innerHTML = '<i class="fas fa-arrow-right"></i> Wrap ETH to WETH9';
|
|
btn.disabled = false;
|
|
}, 3000);
|
|
} catch (error) {
|
|
console.error('Error wrapping WETH9:', error);
|
|
alert('Wrap WETH9: ' + wrapUnwrapErrorMessage('wrap', error));
|
|
document.getElementById('weth9WrapBtn').innerHTML = '<i class="fas fa-arrow-right"></i> Wrap ETH to WETH9';
|
|
document.getElementById('weth9WrapBtn').disabled = false;
|
|
}
|
|
}
|
|
|
|
async function unwrapWETH9() {
|
|
const amount = document.getElementById('weth9UnwrapAmount').value;
|
|
if (!amount || parseFloat(amount) <= 0) {
|
|
alert('Please enter a valid amount');
|
|
return;
|
|
}
|
|
|
|
if (!signer) {
|
|
alert('Please connect MetaMask first');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await ensureEthers();
|
|
const amountWei = parseWETHAmount(amount);
|
|
if (!amountWei || amountWei.isZero()) {
|
|
alert('Please enter a valid amount in ETH or wei (e.g. 100 or 100000000000000000000).');
|
|
return;
|
|
}
|
|
const weth9Contract = new ethers.Contract(WETH9_ADDRESS, WETH_ABI, signer);
|
|
const wethBalance = await weth9Contract.balanceOf(userAddress);
|
|
if (wethBalance.lt(amountWei)) {
|
|
alert('Insufficient WETH9 balance. You have ' + formatEther(wethBalance) + ' WETH9.');
|
|
return;
|
|
}
|
|
try {
|
|
await weth9Contract.callStatic.withdraw(amountWei);
|
|
} catch (e) {
|
|
alert('Simulation failed: ' + (e.reason || e.message || 'Unknown error'));
|
|
return;
|
|
}
|
|
const btn = document.getElementById('weth9UnwrapBtn');
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Processing...';
|
|
|
|
const tx = await weth9Contract.withdraw(amountWei);
|
|
const receipt = await tx.wait();
|
|
|
|
btn.innerHTML = '<i class="fas fa-check"></i> Success!';
|
|
document.getElementById('weth9UnwrapAmount').value = '';
|
|
await refreshWETHBalances();
|
|
|
|
setTimeout(() => {
|
|
btn.innerHTML = '<i class="fas fa-arrow-left"></i> Unwrap WETH9 to ETH';
|
|
btn.disabled = false;
|
|
}, 3000);
|
|
} catch (error) {
|
|
console.error('Error unwrapping WETH9:', error);
|
|
alert('Unwrap WETH9: ' + wrapUnwrapErrorMessage('unwrap', error));
|
|
document.getElementById('weth9UnwrapBtn').innerHTML = '<i class="fas fa-arrow-left"></i> Unwrap WETH9 to ETH';
|
|
document.getElementById('weth9UnwrapBtn').disabled = false;
|
|
}
|
|
}
|
|
|
|
async function wrapWETH10() {
|
|
const amount = document.getElementById('weth10WrapAmount').value;
|
|
if (!amount || parseFloat(amount) <= 0) {
|
|
alert('Please enter a valid amount');
|
|
return;
|
|
}
|
|
|
|
if (!signer) {
|
|
alert('Please connect MetaMask first');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await ensureEthers();
|
|
|
|
// Ensure address is checksummed
|
|
if (typeof ethers !== 'undefined' && ethers.utils) {
|
|
try {
|
|
const lowerAddress = WETH10_ADDRESS_RAW.toLowerCase();
|
|
WETH10_ADDRESS = ethers.utils.getAddress(lowerAddress);
|
|
} catch (e) {
|
|
console.warn('Could not checksum WETH10 address:', e);
|
|
WETH10_ADDRESS = WETH10_ADDRESS_RAW.toLowerCase();
|
|
}
|
|
} else {
|
|
WETH10_ADDRESS = WETH10_ADDRESS_RAW.toLowerCase();
|
|
}
|
|
|
|
const amountWei = parseWETHAmount(amount);
|
|
if (!amountWei || amountWei.isZero()) {
|
|
alert('Please enter a valid amount in ETH or wei (e.g. 100 or 100000000000000000000).');
|
|
return;
|
|
}
|
|
const ethBalance = await provider.getBalance(userAddress);
|
|
if (ethBalance.lt(amountWei)) {
|
|
alert('Insufficient ETH balance. You have ' + formatEther(ethBalance) + ' ETH.');
|
|
return;
|
|
}
|
|
const weth10Contract = new ethers.Contract(WETH10_ADDRESS, WETH_ABI, signer);
|
|
try {
|
|
await weth10Contract.callStatic.deposit({ value: amountWei });
|
|
} catch (e) {
|
|
alert('Simulation failed: ' + (e.reason || e.message || 'Unknown error'));
|
|
return;
|
|
}
|
|
const btn = document.getElementById('weth10WrapBtn');
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Processing...';
|
|
|
|
const tx = await weth10Contract.deposit({ value: amountWei });
|
|
const receipt = await tx.wait();
|
|
|
|
btn.innerHTML = '<i class="fas fa-check"></i> Success!';
|
|
document.getElementById('weth10WrapAmount').value = '';
|
|
await refreshWETHBalances();
|
|
|
|
setTimeout(() => {
|
|
btn.innerHTML = '<i class="fas fa-arrow-right"></i> Wrap ETH to WETH10';
|
|
btn.disabled = false;
|
|
}, 3000);
|
|
} catch (error) {
|
|
console.error('Error wrapping WETH10:', error);
|
|
alert('Wrap WETH10: ' + wrapUnwrapErrorMessage('wrap', error));
|
|
document.getElementById('weth10WrapBtn').innerHTML = '<i class="fas fa-arrow-right"></i> Wrap ETH to WETH10';
|
|
document.getElementById('weth10WrapBtn').disabled = false;
|
|
}
|
|
}
|
|
|
|
async function unwrapWETH10() {
|
|
const amount = document.getElementById('weth10UnwrapAmount').value;
|
|
if (!amount || parseFloat(amount) <= 0) {
|
|
alert('Please enter a valid amount');
|
|
return;
|
|
}
|
|
|
|
if (!signer) {
|
|
alert('Please connect MetaMask first');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await ensureEthers();
|
|
|
|
// Ensure address is checksummed
|
|
if (typeof ethers !== 'undefined' && ethers.utils) {
|
|
try {
|
|
const lowerAddress = WETH10_ADDRESS_RAW.toLowerCase();
|
|
WETH10_ADDRESS = ethers.utils.getAddress(lowerAddress);
|
|
} catch (e) {
|
|
console.warn('Could not checksum WETH10 address:', e);
|
|
WETH10_ADDRESS = WETH10_ADDRESS_RAW.toLowerCase();
|
|
}
|
|
} else {
|
|
WETH10_ADDRESS = WETH10_ADDRESS_RAW.toLowerCase();
|
|
}
|
|
|
|
const amountWei = parseWETHAmount(amount);
|
|
if (!amountWei || amountWei.isZero()) {
|
|
alert('Please enter a valid amount in ETH or wei (e.g. 100 or 100000000000000000000).');
|
|
return;
|
|
}
|
|
const weth10Contract = new ethers.Contract(WETH10_ADDRESS, WETH_ABI, signer);
|
|
const wethBalance = await weth10Contract.balanceOf(userAddress);
|
|
if (wethBalance.lt(amountWei)) {
|
|
alert('Insufficient WETH10 balance. You have ' + formatEther(wethBalance) + ' WETH10.');
|
|
return;
|
|
}
|
|
try {
|
|
await weth10Contract.callStatic.withdraw(amountWei);
|
|
} catch (e) {
|
|
alert('Simulation failed: ' + (e.reason || e.message || 'Unknown error'));
|
|
return;
|
|
}
|
|
const btn = document.getElementById('weth10UnwrapBtn');
|
|
btn.disabled = true;
|
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Processing...';
|
|
|
|
const tx = await weth10Contract.withdraw(amountWei);
|
|
const receipt = await tx.wait();
|
|
|
|
btn.innerHTML = '<i class="fas fa-check"></i> Success!';
|
|
document.getElementById('weth10UnwrapAmount').value = '';
|
|
await refreshWETHBalances();
|
|
|
|
setTimeout(() => {
|
|
btn.innerHTML = '<i class="fas fa-arrow-left"></i> Unwrap WETH10 to ETH';
|
|
btn.disabled = false;
|
|
}, 3000);
|
|
} catch (error) {
|
|
console.error('Error unwrapping WETH10:', error);
|
|
alert('Unwrap WETH10: ' + wrapUnwrapErrorMessage('unwrap', error));
|
|
document.getElementById('weth10UnwrapBtn').innerHTML = '<i class="fas fa-arrow-left"></i> Unwrap WETH10 to ETH';
|
|
document.getElementById('weth10UnwrapBtn').disabled = false;
|
|
}
|
|
}
|
|
|
|
function showWETHTab(tab, clickedElement) {
|
|
document.querySelectorAll('.weth-tab-content').forEach(el => el.style.display = 'none');
|
|
document.querySelectorAll('.weth-tab').forEach(el => el.classList.remove('active'));
|
|
|
|
const tabElement = document.getElementById(`${tab}Tab`);
|
|
if (tabElement) {
|
|
tabElement.style.display = 'block';
|
|
}
|
|
|
|
// Update active tab - use clickedElement if provided, otherwise find by tab name
|
|
if (clickedElement) {
|
|
clickedElement.classList.add('active');
|
|
} else {
|
|
// Find the button that corresponds to this tab
|
|
const tabButtons = document.querySelectorAll('.weth-tab');
|
|
tabButtons.forEach(btn => {
|
|
if (btn.getAttribute('onclick')?.includes(`'${tab}'`)) {
|
|
btn.classList.add('active');
|
|
}
|
|
});
|
|
}
|
|
}
|
|
window.showWETHTab = showWETHTab;
|
|
|
|
async function renderWETHUtilitiesView() {
|
|
showView('weth');
|
|
if ((window.location.pathname || '').replace(/^\//, '').split('/')[0] !== 'weth') updatePath('/weth');
|
|
if (userAddress) {
|
|
await refreshWETHBalances();
|
|
}
|
|
}
|
|
window._showWETHUtilities = renderWETHUtilitiesView;
|
|
|
|
async function renderBridgeView() {
|
|
showView('bridge');
|
|
if ((window.location.pathname || '').replace(/^\//, '').split('/')[0] !== 'bridge') updatePath('/bridge');
|
|
await refreshBridgeData();
|
|
}
|
|
window._showBridgeMonitoring = renderBridgeView;
|
|
|
|
async function renderHomeView() {
|
|
showView('home');
|
|
if ((window.location.pathname || '').replace(/\/$/, '') !== '') updatePath('/');
|
|
await loadStats();
|
|
await loadLatestBlocks();
|
|
await loadLatestTransactions();
|
|
// Start real-time transaction updates
|
|
startTransactionUpdates();
|
|
}
|
|
window._showHome = renderHomeView;
|
|
|
|
async function renderBlocksView() {
|
|
showView('blocks');
|
|
if ((window.location.pathname || '').replace(/^\//, '').split('/')[0] !== 'blocks') updatePath('/blocks');
|
|
await loadAllBlocks();
|
|
}
|
|
window._showBlocks = renderBlocksView;
|
|
|
|
async function renderTransactionsView() {
|
|
showView('transactions');
|
|
if ((window.location.pathname || '').replace(/^\//, '').split('/')[0] !== 'transactions') updatePath('/transactions');
|
|
await loadAllTransactions();
|
|
}
|
|
window._showTransactions = renderTransactionsView;
|
|
|
|
async function renderAddressesView() {
|
|
showView('addresses');
|
|
if ((window.location.pathname || '').replace(/^\//, '').split('/')[0] !== 'addresses') updatePath('/addresses');
|
|
await loadAllAddresses();
|
|
}
|
|
window._showAddresses = renderAddressesView;
|
|
|
|
function buildAnalyticsViewHtml() {
|
|
var html = '';
|
|
html += '<div style="display:grid; gap:1rem;">';
|
|
html += '<div class="card" style="margin:0; box-shadow:none;">';
|
|
html += '<div class="card-header" style="align-items:flex-start; gap:0.75rem;">';
|
|
html += '<div>';
|
|
html += '<h3 class="card-title" style="margin-bottom:0.25rem;"><i class="fas fa-chart-line"></i> Live Network Analytics</h3>';
|
|
html += '<div style="color:var(--text-light); line-height:1.55;">Analytics surfaces are consolidated into the live explorer dashboards instead of a separate unfinished panel. Use this page as a hub to the active gas, block, bridge, and route monitoring views.</div>';
|
|
html += '</div>';
|
|
html += '<div style="display:flex; gap:0.5rem; flex-wrap:wrap; margin-left:auto;">';
|
|
html += '<button type="button" class="btn btn-primary" onclick="showHome(); updatePath(\'/\')" aria-label="Open network dashboard"><i class="fas fa-gauge-high"></i> Network dashboard</button>';
|
|
html += '<button type="button" class="btn btn-secondary" onclick="showBridgeMonitoring(); updatePath(\'/bridge\')" aria-label="Open bridge monitoring"><i class="fas fa-bridge"></i> Bridge monitoring</button>';
|
|
html += '</div>';
|
|
html += '</div>';
|
|
html += '<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:0.75rem;">';
|
|
html += '<a href="/" onclick="event.preventDefault(); showHome(); updatePath(\'/\');" style="display:block; text-decoration:none; color:inherit; border:1px solid var(--border); border-radius:14px; padding:1rem; background:var(--muted-surface);"><div style="font-weight:700; margin-bottom:0.3rem;">Gas & Network</div><div style="color:var(--text-light); line-height:1.5;">Open the live home dashboard for gas price, TPS, block time, validator count, and latest-chain activity.</div></a>';
|
|
html += '<a href="/blocks" onclick="event.preventDefault(); showBlocks(); updatePath(\'/blocks\');" style="display:block; text-decoration:none; color:inherit; border:1px solid var(--border); border-radius:14px; padding:1rem; background:var(--muted-surface);"><div style="font-weight:700; margin-bottom:0.3rem;">Block cadence</div><div style="color:var(--text-light); line-height:1.5;">Inspect live block production, miner attribution, gas usage, and exportable block history.</div></a>';
|
|
html += '<a href="/transactions" onclick="event.preventDefault(); showTransactions(); updatePath(\'/transactions\');" style="display:block; text-decoration:none; color:inherit; border:1px solid var(--border); border-radius:14px; padding:1rem; background:var(--muted-surface);"><div style="font-weight:700; margin-bottom:0.3rem;">Transaction flow</div><div style="color:var(--text-light); line-height:1.5;">Review the recent transaction stream and drill into decoded execution details and internal calls.</div></a>';
|
|
html += '<a href="/routes" onclick="event.preventDefault(); showRoutes(); updatePath(\'/routes\');" style="display:block; text-decoration:none; color:inherit; border:1px solid var(--border); border-radius:14px; padding:1rem; background:var(--muted-surface);"><div style="font-weight:700; margin-bottom:0.3rem;">Route coverage</div><div style="color:var(--text-light); line-height:1.5;">Open the dedicated route-decision tree for swap-path coverage, bridge branches, and missing quote-token diagnostics.</div></a>';
|
|
html += '</div>';
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
// Analytics view
|
|
function renderAnalyticsView() {
|
|
showView('analytics');
|
|
if ((window.location.pathname || '').replace(/^\//, '').split('/')[0] !== 'analytics') updatePath('/analytics');
|
|
var container = document.getElementById('analyticsContent');
|
|
if (!container) return;
|
|
container.innerHTML = buildAnalyticsViewHtml();
|
|
}
|
|
window._showAnalytics = renderAnalyticsView;
|
|
|
|
function buildOperatorViewHtml() {
|
|
var html = '';
|
|
html += '<div style="display:grid; gap:1rem;">';
|
|
html += '<div class="card" style="margin:0; box-shadow:none;">';
|
|
html += '<div class="card-header" style="align-items:flex-start; gap:0.75rem;">';
|
|
html += '<div>';
|
|
html += '<h3 class="card-title" style="margin-bottom:0.25rem;"><i class="fas fa-server"></i> Operator Access Hub</h3>';
|
|
html += '<div style="color:var(--text-light); line-height:1.55;">The explorer does not expose raw privileged controls here. Instead, this page collects the live operator-facing observability and execution surfaces that are safe to browse from the public UI.</div>';
|
|
html += '</div>';
|
|
html += '<div style="display:flex; gap:0.5rem; flex-wrap:wrap; margin-left:auto;">';
|
|
html += '<button type="button" class="btn btn-primary" onclick="showBridgeMonitoring(); updatePath(\'/bridge\')" aria-label="Open bridge monitoring"><i class="fas fa-bridge"></i> Bridge status</button>';
|
|
html += '<button type="button" class="btn btn-secondary" onclick="showLiquidityAccess(); updatePath(\'/liquidity\')" aria-label="Open liquidity access"><i class="fas fa-wave-square"></i> Liquidity APIs</button>';
|
|
html += '</div>';
|
|
html += '</div>';
|
|
html += '<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:0.75rem;">';
|
|
html += '<a href="/bridge" onclick="event.preventDefault(); showBridgeMonitoring(); updatePath(\'/bridge\');" style="display:block; text-decoration:none; color:inherit; border:1px solid var(--border); border-radius:14px; padding:1rem; background:var(--muted-surface);"><div style="font-weight:700; margin-bottom:0.3rem;">Bridge monitoring</div><div style="color:var(--text-light); line-height:1.5;">Inspect bridge balances, fee-token posture, destination configuration, and live contract references.</div></a>';
|
|
html += '<a href="/liquidity" onclick="event.preventDefault(); showLiquidityAccess(); updatePath(\'/liquidity\');" style="display:block; text-decoration:none; color:inherit; border:1px solid var(--border); border-radius:14px; padding:1rem; background:var(--muted-surface);"><div style="font-weight:700; margin-bottom:0.3rem;">Liquidity access</div><div style="color:var(--text-light); line-height:1.5;">Jump to partner payload routes, ingestion APIs, and public execution-plan endpoints without leaving the explorer.</div></a>';
|
|
html += '<a href="/pools" onclick="event.preventDefault(); showPools(); updatePath(\'/pools\');" style="display:block; text-decoration:none; color:inherit; border:1px solid var(--border); border-radius:14px; padding:1rem; background:var(--muted-surface);"><div style="font-weight:700; margin-bottom:0.3rem;">Pool inventory</div><div style="color:var(--text-light); line-height:1.5;">Review canonical PMM addresses, funding state, registry status, and exportable pool snapshots.</div></a>';
|
|
html += '<a href="/weth" onclick="event.preventDefault(); showWETHUtilities(); updatePath(\'/weth\');" style="display:block; text-decoration:none; color:inherit; border:1px solid var(--border); border-radius:14px; padding:1rem; background:var(--muted-surface);"><div style="font-weight:700; margin-bottom:0.3rem;">WETH utilities</div><div style="color:var(--text-light); line-height:1.5;">Open the WETH9/WETH10 utilities, bridge contract references, and balance tools that operators often need during support.</div></a>';
|
|
html += '</div>';
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
// Operator view
|
|
function renderOperatorView() {
|
|
showView('operator');
|
|
if ((window.location.pathname || '').replace(/^\//, '').split('/')[0] !== 'operator') updatePath('/operator');
|
|
var container = document.getElementById('operatorContent');
|
|
if (!container) return;
|
|
container.innerHTML = buildOperatorViewHtml();
|
|
}
|
|
window._showOperator = renderOperatorView;
|
|
|
|
function showView(viewName) {
|
|
currentView = viewName;
|
|
var detailViews = ['blockDetail','transactionDetail','addressDetail','tokenDetail','nftDetail','watchlist','searchResults','tokens','addresses','pools','routes','liquidity','more','analytics','operator'];
|
|
if (detailViews.indexOf(viewName) === -1) currentDetailKey = '';
|
|
document.querySelectorAll('.detail-view').forEach(v => v.classList.remove('active'));
|
|
const homeView = document.getElementById('homeView');
|
|
if (homeView) homeView.style.display = viewName === 'home' ? 'block' : 'none';
|
|
if (viewName !== 'home') {
|
|
const targetView = document.getElementById(`${viewName}View`);
|
|
if (targetView) targetView.classList.add('active');
|
|
stopTransactionUpdates();
|
|
}
|
|
}
|
|
window.showView = showView;
|
|
|
|
function toggleDarkMode() {
|
|
document.body.classList.toggle('dark-theme');
|
|
var isDark = document.body.classList.contains('dark-theme');
|
|
try { localStorage.setItem('explorerTheme', isDark ? 'dark' : 'light'); } catch (e) {}
|
|
var icon = document.getElementById('themeIcon');
|
|
if (icon) {
|
|
icon.className = isDark ? 'fas fa-sun' : 'fas fa-moon';
|
|
}
|
|
}
|
|
function applyStoredTheme() {
|
|
try {
|
|
var theme = localStorage.getItem('explorerTheme');
|
|
if (theme === 'dark') {
|
|
document.body.classList.add('dark-theme');
|
|
var icon = document.getElementById('themeIcon');
|
|
if (icon) icon.className = 'fas fa-sun';
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
function updatePath(path) {
|
|
if (typeof history !== 'undefined' && history.pushState) {
|
|
history.pushState(null, '', path);
|
|
}
|
|
}
|
|
window.updatePath = updatePath;
|
|
function applyHashRoute() {
|
|
var route = '';
|
|
var fromPath = (window.location.pathname || '/').replace(/^\//, '').replace(/\/$/, '').replace(/^index\.html$/i, '');
|
|
var fromHash = (window.location.hash || '').replace(/^#/, '');
|
|
if (fromPath && fromPath !== '') {
|
|
route = fromPath;
|
|
} else if (fromHash) {
|
|
route = fromHash;
|
|
}
|
|
if (!route || route === 'home') { if (currentView !== 'home') showHome(); return; }
|
|
var parts = route.split('/').filter(Boolean);
|
|
var decode = function(s) { try { return decodeURIComponent(s); } catch (e) { return s; } };
|
|
if (parts[0] === 'block' && parts[1]) { var p1 = decode(parts[1]); var key = 'block:' + p1; if (currentDetailKey === key) return; currentDetailKey = key; setTimeout(function() { showBlockDetail(p1); }, 0); return; }
|
|
if (parts[0] === 'tx' && parts[1]) { var p1 = decode(parts[1]); var txKey = 'tx:' + (p1 && typeof p1 === 'string' ? p1.toLowerCase() : String(p1)); if (currentDetailKey === txKey) return; currentDetailKey = txKey; setTimeout(function() { showTransactionDetail(p1); }, 0); return; }
|
|
if (parts[0] === 'address' && parts[1]) { var p1 = decode(parts[1]); var addrKey = 'address:' + (p1 && typeof p1 === 'string' ? p1.toLowerCase() : String(p1)); if (currentDetailKey === addrKey) return; currentDetailKey = addrKey; setTimeout(function() { showAddressDetail(p1); }, 0); return; }
|
|
if (parts[0] === 'token' && parts[1]) { var p1 = decode(parts[1]); var tokKey = 'token:' + (p1 && typeof p1 === 'string' ? p1.toLowerCase() : String(p1)); if (currentDetailKey === tokKey) return; currentDetailKey = tokKey; setTimeout(function() { showTokenDetail(p1); }, 0); return; }
|
|
if (parts[0] === 'nft' && parts[1] && parts[2]) { var p1 = decode(parts[1]), p2 = decode(parts[2]); var nftKey = 'nft:' + (p1 && typeof p1 === 'string' ? p1.toLowerCase() : String(p1)) + ':' + p2; if (currentDetailKey === nftKey) return; currentDetailKey = nftKey; setTimeout(function() { showNftDetail(p1, p2); }, 0); return; }
|
|
if (parts[0] === 'home') { if (currentView !== 'home') showHome(); return; }
|
|
if (parts[0] === 'blocks') { if (currentView !== 'blocks') showBlocks(); return; }
|
|
if (parts[0] === 'transactions') { if (currentView !== 'transactions') showTransactions(); return; }
|
|
if (parts[0] === 'addresses') { if (currentView !== 'addresses') showAddresses(); return; }
|
|
if (parts[0] === 'bridge') { if (currentView !== 'bridge') showBridgeMonitoring(); return; }
|
|
if (parts[0] === 'weth') { if (currentView !== 'weth') showWETHUtilities(); return; }
|
|
if (parts[0] === 'watchlist') { if (currentView !== 'watchlist') showWatchlist(); return; }
|
|
if (parts[0] === 'pools') { if (currentView !== 'pools') openPoolsView(); return; }
|
|
if (parts[0] === 'routes') { if (currentView !== 'routes') showRoutes(); return; }
|
|
if (parts[0] === 'liquidity') { if (currentView !== 'liquidity') showLiquidityAccess(); return; }
|
|
if (parts[0] === 'more') { if (currentView !== 'more') showMore(); return; }
|
|
if (parts[0] === 'tokens') { if (typeof showTokensList === 'function') showTokensList(); else focusSearchWithHint('token'); return; }
|
|
if (parts[0] === 'analytics') { if (currentView !== 'analytics') showAnalytics(); return; }
|
|
if (parts[0] === 'operator') { if (currentView !== 'operator') showOperator(); return; }
|
|
}
|
|
window.applyHashRoute = applyHashRoute;
|
|
var hasRouteOnReady = window.location.hash || ((window.location.pathname || '').replace(/^\//, '').replace(/\/$/, ''));
|
|
if (document.readyState !== 'loading' && hasRouteOnReady) { applyHashRoute(); }
|
|
window.toggleDarkMode = toggleDarkMode;
|
|
|
|
function focusSearchWithHint(kind) {
|
|
var prefill = kind === 'token' ? 'token' : '';
|
|
if (kind === 'token') {
|
|
showToast('Enter token contract address (0x...) or search by name/symbol', 'info', 4000);
|
|
}
|
|
openSmartSearchModal(prefill);
|
|
}
|
|
window.focusSearchWithHint = focusSearchWithHint;
|
|
|
|
function toggleNavMenu() {
|
|
var links = document.getElementById('navLinks');
|
|
var btn = document.getElementById('navToggle');
|
|
var icon = document.getElementById('navToggleIcon');
|
|
if (!links || !btn) return;
|
|
var isOpen = links.classList.toggle('nav-open');
|
|
btn.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
|
if (icon) icon.className = isOpen ? 'fas fa-times' : 'fas fa-bars';
|
|
}
|
|
function closeNavMenu() {
|
|
var links = document.getElementById('navLinks');
|
|
var btn = document.getElementById('navToggle');
|
|
var icon = document.getElementById('navToggleIcon');
|
|
if (links) links.classList.remove('nav-open');
|
|
if (btn) btn.setAttribute('aria-expanded', 'false');
|
|
if (icon) icon.className = 'fas fa-bars';
|
|
closeAllNavDropdowns();
|
|
}
|
|
function closeAllNavDropdowns() {
|
|
document.querySelectorAll('.nav-dropdown.open').forEach(function (el) {
|
|
el.classList.remove('open');
|
|
var trigger = el.querySelector('.nav-dropdown-trigger');
|
|
if (trigger) trigger.setAttribute('aria-expanded', 'false');
|
|
});
|
|
}
|
|
function initNavDropdowns() {
|
|
document.querySelectorAll('.nav-dropdown-trigger').forEach(function (trigger) {
|
|
trigger.addEventListener('click', function (e) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
var dropdown = trigger.closest('.nav-dropdown');
|
|
var wasOpen = dropdown && dropdown.classList.contains('open');
|
|
closeAllNavDropdowns();
|
|
if (dropdown && !wasOpen) {
|
|
dropdown.classList.add('open');
|
|
trigger.setAttribute('aria-expanded', 'true');
|
|
}
|
|
});
|
|
});
|
|
document.addEventListener('click', function () {
|
|
closeAllNavDropdowns();
|
|
});
|
|
document.getElementById('navLinks').addEventListener('click', function (e) {
|
|
if (e.target.closest('.nav-dropdown-menu')) e.stopPropagation();
|
|
});
|
|
}
|
|
window.toggleNavMenu = toggleNavMenu;
|
|
window.closeNavMenu = closeNavMenu;
|
|
window.closeAllNavDropdowns = closeAllNavDropdowns;
|
|
|
|
// Update breadcrumb navigation
|
|
function updateBreadcrumb(type, identifier, identifierExtra) {
|
|
let breadcrumbContainer;
|
|
let breadcrumbHTML = '<a href="/">Home</a>';
|
|
switch (type) {
|
|
case 'block':
|
|
breadcrumbContainer = document.getElementById('blockDetailBreadcrumb');
|
|
breadcrumbHTML += '<span class="breadcrumb-separator">/</span>';
|
|
breadcrumbHTML += '<a href="/blocks">Blocks</a>';
|
|
breadcrumbHTML += '<span class="breadcrumb-separator">/</span>';
|
|
breadcrumbHTML += '<span class="breadcrumb-current">Block #' + escapeHtml(String(identifier)) + '</span>';
|
|
break;
|
|
case 'transaction':
|
|
breadcrumbContainer = document.getElementById('transactionDetailBreadcrumb');
|
|
breadcrumbHTML += '<span class="breadcrumb-separator">/</span>';
|
|
breadcrumbHTML += '<a href="/transactions">Transactions</a>';
|
|
breadcrumbHTML += '<span class="breadcrumb-separator">/</span>';
|
|
breadcrumbHTML += '<span class="breadcrumb-current">' + escapeHtml(shortenHash(identifier)) + '</span>';
|
|
break;
|
|
case 'address':
|
|
breadcrumbContainer = document.getElementById('addressDetailBreadcrumb');
|
|
breadcrumbHTML += '<span class="breadcrumb-separator">/</span><a href="/addresses">Addresses</a><span class="breadcrumb-separator">/</span><span class="breadcrumb-current">' + escapeHtml(shortenHash(identifier)) + '</span>';
|
|
break;
|
|
case 'token':
|
|
breadcrumbContainer = document.getElementById('tokenDetailBreadcrumb');
|
|
breadcrumbHTML += '<span class="breadcrumb-separator">/</span>';
|
|
breadcrumbHTML += '<a href="/tokens">Tokens</a>';
|
|
breadcrumbHTML += '<span class="breadcrumb-separator">/</span>';
|
|
breadcrumbHTML += '<span class="breadcrumb-current">Token ' + escapeHtml(shortenHash(identifier)) + '</span>';
|
|
break;
|
|
case 'pools':
|
|
breadcrumbContainer = document.getElementById('poolsBreadcrumb');
|
|
breadcrumbHTML += '<span class="breadcrumb-separator">/</span>';
|
|
breadcrumbHTML += '<span class="breadcrumb-current">Pools</span>';
|
|
break;
|
|
case 'routes':
|
|
breadcrumbContainer = document.getElementById('routesBreadcrumb');
|
|
breadcrumbHTML += '<span class="breadcrumb-separator">/</span>';
|
|
breadcrumbHTML += '<span class="breadcrumb-current">Routes</span>';
|
|
break;
|
|
case 'more':
|
|
breadcrumbContainer = document.getElementById('moreBreadcrumb');
|
|
breadcrumbHTML += '<span class="breadcrumb-separator">/</span>';
|
|
breadcrumbHTML += '<span class="breadcrumb-current">More</span>';
|
|
break;
|
|
case 'nft':
|
|
breadcrumbContainer = document.getElementById('nftDetailBreadcrumb');
|
|
breadcrumbHTML += '<span class="breadcrumb-separator">/</span>';
|
|
breadcrumbHTML += '<a href="/address/' + encodeURIComponent(identifier) + '">' + escapeHtml(shortenHash(identifier)) + '</a>';
|
|
breadcrumbHTML += '<span class="breadcrumb-separator">/</span>';
|
|
breadcrumbHTML += '<span class="breadcrumb-current">Token ID ' + (identifierExtra != null ? escapeHtml(String(identifierExtra)) : '') + '</span>';
|
|
break;
|
|
default:
|
|
return;
|
|
}
|
|
if (breadcrumbContainer) {
|
|
breadcrumbContainer.innerHTML = breadcrumbHTML;
|
|
}
|
|
}
|
|
|
|
// Retry logic with exponential backoff
|
|
async function fetchAPIWithRetry(url, maxRetries = FETCH_MAX_RETRIES, retryDelay = RETRY_DELAY_MS) {
|
|
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
try {
|
|
return await fetchAPI(url);
|
|
} catch (error) {
|
|
const isLastAttempt = attempt === maxRetries - 1;
|
|
const isRetryable = error.name === 'AbortError' ||
|
|
(error.message && (error.message.includes('timeout') ||
|
|
error.message.includes('500') ||
|
|
error.message.includes('502') ||
|
|
error.message.includes('503') ||
|
|
error.message.includes('504') ||
|
|
error.message.includes('NetworkError')));
|
|
|
|
if (isLastAttempt || !isRetryable) {
|
|
throw error;
|
|
}
|
|
|
|
// Exponential backoff: 1s, 2s, 4s
|
|
const delay = retryDelay * Math.pow(2, attempt);
|
|
console.warn(`⚠️ API call failed (attempt ${attempt + 1}/${maxRetries}), retrying in ${delay}ms...`, error.message);
|
|
await new Promise(resolve => setTimeout(resolve, delay));
|
|
}
|
|
}
|
|
}
|
|
|
|
async function fetchAPI(url) {
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: 'GET',
|
|
headers: { 'Accept': 'application/json' },
|
|
credentials: 'omit',
|
|
signal: controller.signal
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
if (!response.ok) {
|
|
let errorText = '';
|
|
try {
|
|
errorText = await response.text();
|
|
} catch (e) {
|
|
errorText = response.statusText;
|
|
}
|
|
|
|
// Log detailed error for debugging
|
|
const errorInfo = {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
errorText: errorText.substring(0, 500),
|
|
url: url,
|
|
headers: Object.fromEntries(response.headers.entries())
|
|
};
|
|
console.error(`❌ API Error:`, errorInfo);
|
|
if (response.status === 502 || response.status === 503) {
|
|
showAPIUnavailableBanner(response.status);
|
|
}
|
|
// For 400 errors, provide more context
|
|
if (response.status === 400) {
|
|
console.error('🔍 HTTP 400 Bad Request Details:');
|
|
console.error('URL:', url);
|
|
console.error('Response Headers:', errorInfo.headers);
|
|
console.error('Error Body:', errorText);
|
|
console.error('Possible causes:');
|
|
console.error('1. Invalid query parameters');
|
|
console.error('2. Missing required parameters');
|
|
console.error('3. API endpoint format incorrect');
|
|
console.error('4. CORS preflight failed');
|
|
console.error('5. Request method not allowed');
|
|
|
|
// Try to parse error if it's JSON
|
|
try {
|
|
const errorJson = JSON.parse(errorText);
|
|
console.error('Parsed Error:', errorJson);
|
|
} catch (e) {
|
|
// Not JSON, that's fine
|
|
}
|
|
}
|
|
|
|
throw new Error(`HTTP ${response.status}: ${errorText || response.statusText}`);
|
|
}
|
|
const contentType = response.headers.get('content-type');
|
|
if (contentType && contentType.includes('application/json')) {
|
|
return await response.json();
|
|
}
|
|
const text = await response.text();
|
|
try {
|
|
return JSON.parse(text);
|
|
} catch (e) {
|
|
throw new Error(`Invalid JSON response: ${text.substring(0, 100)}`);
|
|
}
|
|
} catch (error) {
|
|
clearTimeout(timeoutId);
|
|
if (error.name === 'AbortError') {
|
|
throw new Error('Request timeout. Please try again.');
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function postJSON(url, payload) {
|
|
const controller = new AbortController();
|
|
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS * 2);
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Accept': 'application/json',
|
|
'Content-Type': 'application/json'
|
|
},
|
|
credentials: 'omit',
|
|
signal: controller.signal,
|
|
body: JSON.stringify(payload || {})
|
|
});
|
|
|
|
clearTimeout(timeoutId);
|
|
const text = await response.text();
|
|
let parsed = {};
|
|
if (text) {
|
|
try {
|
|
parsed = JSON.parse(text);
|
|
} catch (e) {
|
|
parsed = { reply: text };
|
|
}
|
|
}
|
|
|
|
if (!response.ok) {
|
|
var message = (parsed && parsed.error && (parsed.error.message || parsed.error.code)) || text || response.statusText || 'Request failed';
|
|
throw new Error('HTTP ' + response.status + ': ' + message);
|
|
}
|
|
|
|
return parsed;
|
|
} catch (error) {
|
|
clearTimeout(timeoutId);
|
|
if (error.name === 'AbortError') {
|
|
throw new Error('Request timeout. Please try again.');
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function loadStats() {
|
|
const statsGrid = document.getElementById('statsGrid');
|
|
if (!statsGrid) return;
|
|
|
|
// Show skeleton loader
|
|
statsGrid.innerHTML = createSkeletonLoader('stats');
|
|
|
|
try {
|
|
let stats;
|
|
|
|
// For ChainID 138, use Blockscout API
|
|
if (CHAIN_ID === 138) {
|
|
// Blockscout doesn't have a single stats endpoint, so we'll fetch from our API
|
|
// or use Blockscout's individual endpoints
|
|
try {
|
|
// Try our API first
|
|
stats = await fetchAPIWithRetry(`${API_BASE}/v2/stats`);
|
|
} catch (e) {
|
|
// Fallback: fetch from Blockscout and calculate
|
|
const [blocksRes, txsRes] = await Promise.all([
|
|
fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/blocks?page=1&page_size=1`).catch(() => null),
|
|
fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/transactions?page=1&page_size=1`).catch(() => null)
|
|
]);
|
|
|
|
stats = {
|
|
total_blocks: blocksRes?.items?.[0]?.number || 0,
|
|
total_transactions: txsRes?.items?.length ? 'N/A' : 0,
|
|
total_addresses: 0
|
|
};
|
|
}
|
|
} else {
|
|
// For other networks, use v2 API
|
|
stats = await fetchAPIWithRetry(`${API_BASE}/v2/stats`);
|
|
}
|
|
|
|
const activeBridgeContracts = getActiveBridgeContractCount();
|
|
statsGrid.innerHTML = `
|
|
<div class="stat-card">
|
|
<div class="stat-label">Total Blocks</div>
|
|
<div class="stat-value">${formatNumber(stats.total_blocks || 0)}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Total Transactions</div>
|
|
<div class="stat-value">${formatNumber(stats.total_transactions || 0)}</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Total Addresses</div>
|
|
<div class="stat-value">${formatNumber(stats.total_addresses || 0)}</div>
|
|
</div>
|
|
<div class="stat-card bridge-card">
|
|
<div class="stat-label">Bridge Contracts</div>
|
|
<div class="stat-value bridge-value">${activeBridgeContracts} Active</div>
|
|
</div>
|
|
<div class="stat-card" id="networkStatCard">
|
|
<div class="stat-label">Network</div>
|
|
<div class="stat-value" id="networkStatValue" style="font-size: 1rem;">Loading...</div>
|
|
</div>
|
|
`;
|
|
if (CHAIN_ID === 138) loadGasAndNetworkStats();
|
|
} catch (error) {
|
|
console.error('Failed to load stats:', error);
|
|
statsGrid.innerHTML = `
|
|
<div class="stat-card">
|
|
<div class="stat-label">Total Blocks</div>
|
|
<div class="stat-value">-</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Total Transactions</div>
|
|
<div class="stat-value">-</div>
|
|
</div>
|
|
<div class="stat-card">
|
|
<div class="stat-label">Total Addresses</div>
|
|
<div class="stat-value">-</div>
|
|
</div>
|
|
<div class="stat-card bridge-card">
|
|
<div class="stat-label">Bridge Contracts</div>
|
|
<div class="stat-value bridge-value">${activeBridgeContracts} Active</div>
|
|
</div>
|
|
<div class="stat-card"><div class="stat-label">Network</div><div class="stat-value" style="font-size: 1rem;">-</div></div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
async function loadGasAndNetworkStats() {
|
|
var el = document.getElementById('networkStatValue');
|
|
var gasCard = document.getElementById('gasNetworkCard');
|
|
var summaryEl = document.getElementById('gasNetworkSummary');
|
|
try {
|
|
var blocksResp = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/blocks?page=1&page_size=20');
|
|
var blocks = blocksResp.items || blocksResp || [];
|
|
var statsResp = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/stats').catch(function() { return {}; });
|
|
var gasGwei = '-';
|
|
var blockTimeSec = '-';
|
|
var tps = '-';
|
|
if (blocks.length > 0) {
|
|
var b = blocks[0];
|
|
var baseFee = b.base_fee_per_gas || b.base_fee;
|
|
if (baseFee != null) gasGwei = (Number(baseFee) / 1e9).toFixed(2) + ' Gwei';
|
|
}
|
|
if (blocks.length >= 2) {
|
|
var t0 = blocks[0].timestamp;
|
|
var t1 = blocks[1].timestamp;
|
|
if (t0 && t1) {
|
|
var d = (new Date(t0) - new Date(t1)) / 1000;
|
|
if (d > 0) blockTimeSec = d.toFixed(1) + 's';
|
|
}
|
|
}
|
|
if (statsResp.average_block_time != null) blockTimeSec = Number(statsResp.average_block_time).toFixed(1) + 's';
|
|
if (statsResp.transactions_per_second != null) tps = Number(statsResp.transactions_per_second).toFixed(2);
|
|
if (el) el.innerHTML = (gasGwei !== '-' ? 'Gas: ' + escapeHtml(gasGwei) + '<br/>' : '') + (blockTimeSec !== '-' ? 'Block: ' + escapeHtml(blockTimeSec) + '<br/>' : '') + (tps !== '-' ? 'TPS: ' + escapeHtml(tps) : '') || 'Gas / TPS';
|
|
if (summaryEl) {
|
|
summaryEl.textContent = 'Live chain health for Chain 138 updated ' + new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) + '.';
|
|
}
|
|
if (gasCard && CHAIN_ID === 138) {
|
|
var curEl = document.getElementById('gasCurrentValue');
|
|
var tpsEl = document.getElementById('gasTpsValue');
|
|
var btEl = document.getElementById('gasBlockTimeValue');
|
|
var failedEl = document.getElementById('gasFailedRateValue');
|
|
var barsEl = document.getElementById('gasHistoryBars');
|
|
if (curEl) curEl.textContent = gasGwei !== '-' ? gasGwei : '—';
|
|
if (tpsEl) tpsEl.textContent = tps !== '-' ? tps : '—';
|
|
if (btEl) btEl.textContent = blockTimeSec !== '-' ? blockTimeSec : '—';
|
|
if (failedEl) {
|
|
var txsResp = await fetch(BLOCKSCOUT_API + '/v2/transactions?page=1&page_size=100').then(function(r) { return r.json(); }).catch(function() { return { items: [] }; });
|
|
var txs = txsResp.items || txsResp || [];
|
|
var failed = txs.filter(function(t) { var s = t.status; return s === 0 || s === '0' || (t.block && t.block.success === false); }).length;
|
|
failedEl.textContent = txs.length > 0 ? (100 * failed / txs.length).toFixed(2) + '%' : '—';
|
|
}
|
|
if (barsEl) {
|
|
var recent = blocks.slice(0, 10);
|
|
var fees = recent.map(function(bl) { var f = bl.base_fee_per_gas || bl.base_fee; return f != null ? Number(f) / 1e9 : 0; });
|
|
var maxFee = Math.max.apply(null, fees) || 1;
|
|
barsEl.innerHTML = fees.map(function(g, i) {
|
|
var pct = maxFee > 0 ? (g / maxFee * 100) : 0;
|
|
return '<span title="Block ' + (recent[i] && (recent[i].height != null ? recent[i].height : recent[i].number) || '') + ': ' + g.toFixed(2) + ' Gwei" style="width: 8px; height: ' + (pct + 5) + '%; min-height: 4px; background: var(--primary); border-radius: 4px 4px 0 0;"></span>';
|
|
}).join('');
|
|
}
|
|
}
|
|
} catch (e) {
|
|
if (el) el.textContent = '-';
|
|
if (summaryEl) summaryEl.textContent = 'Live chain health unavailable right now.';
|
|
}
|
|
}
|
|
|
|
function normalizeBlockDisplay(block) {
|
|
var blockNum = block.number;
|
|
var hash = block.hash || '';
|
|
var txCount = block.transaction_count || 0;
|
|
var date = null;
|
|
if (block.timestamp) {
|
|
if (typeof block.timestamp === 'string' && block.timestamp.indexOf('0x') === 0) {
|
|
date = new Date(parseInt(block.timestamp, 16) * 1000);
|
|
} else {
|
|
date = new Date(block.timestamp);
|
|
}
|
|
}
|
|
var timestampFormatted = date && !isNaN(date.getTime()) ? date.toLocaleString() : 'N/A';
|
|
var timeAgo = date && !isNaN(date.getTime()) ? getTimeAgo(date) : 'N/A';
|
|
return { blockNum: blockNum, hash: hash, txCount: txCount, timestampFormatted: timestampFormatted, timeAgo: timeAgo };
|
|
}
|
|
|
|
function createBlockCardHtml(block, options) {
|
|
options = options || {};
|
|
var d = normalizeBlockDisplay(block);
|
|
var animationClass = options.animationClass || '';
|
|
return '<div class="block-card ' + escapeHtml(animationClass) + '" onclick="showBlockDetail(\'' + escapeHtml(String(d.blockNum)) + '\')">' +
|
|
'<div class="block-number">#' + escapeHtml(String(d.blockNum)) + '</div>' +
|
|
'<div class="block-hash">' + escapeHtml(shortenHash(d.hash)) + '</div>' +
|
|
'<div class="block-info">' +
|
|
'<div class="block-info-item"><span class="block-info-label">Transactions</span><span class="block-info-value">' + escapeHtml(String(d.txCount)) + '</span></div>' +
|
|
'<div class="block-info-item"><span class="block-info-label">Time</span><span class="block-info-value">' + escapeHtml(d.timeAgo) + '</span></div>' +
|
|
'</div></div>';
|
|
}
|
|
|
|
// Prevent multiple simultaneous calls
|
|
let loadingBlocks = false;
|
|
async function loadLatestBlocks() {
|
|
const container = document.getElementById('latestBlocks');
|
|
if (!container) return;
|
|
|
|
// Prevent multiple simultaneous calls
|
|
if (loadingBlocks) {
|
|
console.log('loadLatestBlocks already in progress, skipping...');
|
|
return;
|
|
}
|
|
loadingBlocks = true;
|
|
|
|
try {
|
|
let blocks = [];
|
|
|
|
// For ChainID 138, use Blockscout API
|
|
if (CHAIN_ID === 138) {
|
|
try {
|
|
const blockscoutUrl = `${BLOCKSCOUT_API}/v2/blocks?page=1&page_size=10`;
|
|
console.log('Fetching blocks from Blockscout:', blockscoutUrl);
|
|
const response = await fetchAPIWithRetry(blockscoutUrl);
|
|
const raw = (response && (response.items || response.data || response.blocks)) || (Array.isArray(response) ? response : null);
|
|
if (raw && Array.isArray(raw)) {
|
|
blocks = raw.slice(0, 10).map(normalizeBlock).filter(b => b !== null);
|
|
console.log(`✅ Loaded ${blocks.length} blocks from Blockscout`);
|
|
} else if (response && typeof response === 'object') {
|
|
blocks = [];
|
|
console.warn('Blockscout blocks response empty or unexpected shape:', Object.keys(response || {}));
|
|
}
|
|
} catch (blockscoutError) {
|
|
console.warn('Blockscout API failed, trying RPC fallback:', blockscoutError.message);
|
|
try {
|
|
const blockNumHex = await rpcCall('eth_blockNumber');
|
|
const latestBlock = parseInt(blockNumHex, 16);
|
|
if (!isNaN(latestBlock) && latestBlock >= 0) {
|
|
for (let i = 0; i < Math.min(10, latestBlock + 1); i++) {
|
|
const bn = latestBlock - i;
|
|
const b = await rpcCall('eth_getBlockByNumber', ['0x' + bn.toString(16), false]);
|
|
if (b && b.number) blocks.push({ number: parseInt(b.number, 16), hash: b.hash, timestamp: b.timestamp ? (typeof b.timestamp === 'string' ? parseInt(b.timestamp, 16) * 1000 : b.timestamp) : null, transaction_count: b.transactions ? b.transactions.length : 0 });
|
|
}
|
|
if (blocks.length > 0) console.log('Loaded ' + blocks.length + ' blocks via RPC fallback');
|
|
}
|
|
} catch (rpcErr) {
|
|
console.error('RPC fallback also failed:', rpcErr);
|
|
if (container) {
|
|
container.innerHTML = '<div class="error">API temporarily unavailable. ' + escapeHtml((blockscoutError.message || 'Unknown error').substring(0, 150)) + ' <button type="button" class="btn btn-primary" onclick="loadLatestBlocks()" style="margin-top: 0.5rem;">Retry</button></div>';
|
|
}
|
|
return;
|
|
}
|
|
if (blocks.length === 0 && container) {
|
|
container.innerHTML = '<div class="error">Could not load blocks. <button type="button" class="btn btn-primary" onclick="loadLatestBlocks()" style="margin-top: 0.5rem;">Retry</button></div>';
|
|
return;
|
|
}
|
|
}
|
|
} else {
|
|
// For other networks, use Etherscan-compatible API
|
|
const blockData = await fetchAPI(`${API_BASE}?module=block&action=eth_block_number`);
|
|
if (!blockData || !blockData.result) {
|
|
throw new Error('Invalid response from API');
|
|
}
|
|
|
|
const latestBlock = parseInt(blockData.result, 16);
|
|
if (isNaN(latestBlock) || latestBlock < 0) {
|
|
throw new Error('Invalid block number');
|
|
}
|
|
|
|
// Fetch blocks one by one
|
|
for (let i = 0; i < 10 && latestBlock - i >= 0; i++) {
|
|
const blockNum = latestBlock - i;
|
|
try {
|
|
const block = await fetchAPI(`${API_BASE}?module=block&action=eth_get_block_by_number&tag=0x${blockNum.toString(16)}&boolean=false`);
|
|
if (block && block.result) {
|
|
blocks.push({
|
|
number: blockNum,
|
|
hash: block.result.hash,
|
|
timestamp: block.result.timestamp,
|
|
transaction_count: block.result.transactions ? block.result.transactions.length : 0
|
|
});
|
|
}
|
|
} catch (e) {
|
|
console.warn(`Failed to load block ${blockNum}:`, e);
|
|
}
|
|
}
|
|
}
|
|
|
|
const limitedBlocks = blocks.slice(0, 10);
|
|
const blockFilter = getExplorerPageFilter('homeBlocks');
|
|
const filteredBlocks = blockFilter ? limitedBlocks.filter(function(block) {
|
|
var d = normalizeBlockDisplay(block);
|
|
return matchesExplorerFilter([d.blockNum, d.hash, d.txCount, d.timestampFormatted, d.timeAgo].join(' '), blockFilter);
|
|
}) : limitedBlocks;
|
|
const filterBar = renderPageFilterBar('homeBlocks', 'Filter blocks by number, hash, tx count, or age...', 'Filters the live block cards below.', 'loadLatestBlocks()');
|
|
|
|
if (limitedBlocks.length === 0) {
|
|
if (container) container.innerHTML = filterBar + '<div style="text-align: center; padding: 2rem; color: var(--text-light);">No blocks found. <button type="button" class="btn btn-primary" onclick="loadLatestBlocks()" style="margin-top: 0.5rem;">Retry</button></div>';
|
|
} else if (filteredBlocks.length === 0) {
|
|
if (container) container.innerHTML = filterBar + '<div style="text-align: center; padding: 2rem; color: var(--text-light);">No blocks match the current filter.</div>';
|
|
} else {
|
|
// Create HTML with duplicated blocks for seamless infinite loop
|
|
let html = filterBar + '<div class="blocks-scroll-container" id="blocksScrollContainer">';
|
|
html += '<div class="blocks-scroll-content">';
|
|
|
|
// First set of blocks (with animations for first 3)
|
|
filteredBlocks.forEach(function(block, index) {
|
|
var animationClass = index < 3 ? 'new-block' : '';
|
|
html += createBlockCardHtml(block, { animationClass: animationClass });
|
|
});
|
|
|
|
// Duplicate blocks for seamless infinite loop
|
|
filteredBlocks.forEach(function(block) {
|
|
html += createBlockCardHtml(block, {});
|
|
});
|
|
|
|
html += '</div></div>';
|
|
if (container) container.innerHTML = html;
|
|
|
|
// Setup auto-scroll animation
|
|
const scrollContainer = document.getElementById('blocksScrollContainer');
|
|
const scrollContent = scrollContainer?.querySelector('.blocks-scroll-content');
|
|
if (scrollContainer && scrollContent) {
|
|
const cardWidth = 200 + 16; // card width (200px) + gap (16px = 1rem)
|
|
const singleSetWidth = filteredBlocks.length * cardWidth;
|
|
|
|
// Use CSS transform for smooth animation
|
|
let scrollPosition = 0;
|
|
let isPaused = false;
|
|
const scrollSpeed = 0.4; // pixels per frame (adjust for speed)
|
|
|
|
scrollContainer.addEventListener('mouseenter', () => {
|
|
isPaused = true;
|
|
});
|
|
|
|
scrollContainer.addEventListener('mouseleave', () => {
|
|
isPaused = false;
|
|
});
|
|
|
|
if (_blocksScrollAnimationId != null) {
|
|
cancelAnimationFrame(_blocksScrollAnimationId);
|
|
_blocksScrollAnimationId = null;
|
|
}
|
|
function animateScroll() {
|
|
if (!isPaused && scrollContent) {
|
|
scrollPosition += scrollSpeed;
|
|
if (scrollPosition >= singleSetWidth) scrollPosition = 0;
|
|
scrollContent.style.transform = 'translateX(-' + scrollPosition + 'px)';
|
|
}
|
|
_blocksScrollAnimationId = requestAnimationFrame(animateScroll);
|
|
}
|
|
setTimeout(function() {
|
|
_blocksScrollAnimationId = requestAnimationFrame(animateScroll);
|
|
}, 500);
|
|
}
|
|
|
|
// Remove animation classes after animation completes
|
|
setTimeout(() => {
|
|
container.querySelectorAll('.new-block').forEach(card => {
|
|
card.classList.remove('new-block');
|
|
});
|
|
}, 1000);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load latest blocks:', error);
|
|
if (container) container.innerHTML = '<div class="error">Failed to load blocks: ' + escapeHtml((error.message || 'Unknown error').substring(0, 200)) + '. <button type="button" class="btn btn-primary" onclick="loadLatestBlocks()" style="margin-top: 0.5rem;">Retry</button></div>';
|
|
} finally {
|
|
loadingBlocks = false;
|
|
}
|
|
}
|
|
|
|
// Store previous transaction hashes for real-time updates
|
|
let previousTransactionHashes = new Set();
|
|
let transactionUpdateInterval = null;
|
|
|
|
async function loadLatestTransactions() {
|
|
const container = document.getElementById('latestTransactions');
|
|
if (!container) return;
|
|
|
|
try {
|
|
let response;
|
|
let rawTransactions = [];
|
|
|
|
// For ChainID 138, use Blockscout API
|
|
if (CHAIN_ID === 138) {
|
|
try {
|
|
response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/transactions?page=1&page_size=10`);
|
|
rawTransactions = Array.isArray(response?.items) ? response.items : (Array.isArray(response?.data) ? response.data : []);
|
|
} catch (apiErr) {
|
|
console.warn('Blockscout transactions API failed, trying RPC fallback:', apiErr.message);
|
|
try {
|
|
const blockNumHex = await rpcCall('eth_blockNumber');
|
|
const latest = parseInt(blockNumHex, 16);
|
|
for (let i = 0; i < Math.min(5, latest + 1) && rawTransactions.length < 10; i++) {
|
|
const b = await rpcCall('eth_getBlockByNumber', ['0x' + (latest - i).toString(16), true]);
|
|
if (b && b.transactions) {
|
|
b.transactions.forEach(tx => {
|
|
if (typeof tx === 'object' && rawTransactions.length < 10) rawTransactions.push({ hash: tx.hash, from: tx.from, to: tx.to || null, value: tx.value || '0x0', block_number: parseInt(b.number, 16), created_at: b.timestamp ? new Date(parseInt(b.timestamp, 16) * 1000).toISOString() : null });
|
|
});
|
|
}
|
|
}
|
|
response = { items: rawTransactions };
|
|
} catch (rpcErr) {
|
|
console.error('RPC transactions fallback failed:', rpcErr);
|
|
if (container) container.innerHTML = '<div class="error">API temporarily unavailable. <button type="button" class="btn btn-primary" onclick="loadLatestTransactions()" style="margin-top: 0.5rem;">Retry</button></div>';
|
|
return;
|
|
}
|
|
}
|
|
} else {
|
|
response = await fetchAPIWithRetry(`${API_BASE}/v2/transactions?page=1&page_size=10`);
|
|
rawTransactions = Array.isArray(response?.items) ? response.items : (Array.isArray(response?.data) ? response.data : []);
|
|
}
|
|
|
|
// Normalize transactions using adapter
|
|
const transactions = rawTransactions.map(normalizeTransaction).filter(tx => tx !== null);
|
|
|
|
// Limit to 10 transactions
|
|
const limitedTransactions = transactions.slice(0, 10);
|
|
const txFilter = getExplorerPageFilter('homeTransactions');
|
|
const filteredTransactions = txFilter ? limitedTransactions.filter(function(tx) {
|
|
const hash = String(tx.hash || '');
|
|
const from = String(tx.from || '');
|
|
const to = String(tx.to || '');
|
|
const blockNumber = String(tx.block_number || '');
|
|
const value = String(tx.value || '0');
|
|
return matchesExplorerFilter([hash, from, to, blockNumber, formatEther(value)].join(' '), txFilter);
|
|
}) : limitedTransactions;
|
|
const filterBar = renderPageFilterBar('homeTransactions', 'Filter by hash, address, block, or value...', 'Filters the live transaction table below.', 'loadLatestTransactions()');
|
|
|
|
// Check for new transactions
|
|
const currentHashes = new Set(filteredTransactions.map(tx => String(tx.hash || '')));
|
|
const newTransactions = filteredTransactions.filter(tx => !previousTransactionHashes.has(String(tx.hash || '')));
|
|
|
|
// Update previous hashes
|
|
previousTransactionHashes = currentHashes;
|
|
|
|
// Show skeleton loader only on first load
|
|
if (container.innerHTML.includes('skeleton') || container.innerHTML.includes('Loading')) {
|
|
container.innerHTML = createSkeletonLoader('table');
|
|
}
|
|
|
|
let html = filterBar + '<table class="table"><thead><tr><th>Hash</th><th>From</th><th>To</th><th>Value</th><th>Block</th></tr></thead><tbody>';
|
|
|
|
if (limitedTransactions.length === 0) {
|
|
html += '<tr><td colspan="5" style="text-align: center; padding: 1rem;">No transactions found</td></tr>';
|
|
} else if (filteredTransactions.length === 0) {
|
|
html += '<tr><td colspan="5" style="text-align: center; padding: 1rem;">No transactions match the current filter.</td></tr>';
|
|
} else {
|
|
filteredTransactions.forEach((tx, index) => {
|
|
// Transaction is already normalized by adapter
|
|
const hash = String(tx.hash || 'N/A');
|
|
const from = String(tx.from || 'N/A');
|
|
const to = String(tx.to || 'N/A');
|
|
const value = tx.value || '0';
|
|
const blockNumber = tx.block_number || 'N/A';
|
|
|
|
const valueFormatted = formatEther(value);
|
|
|
|
// Add animation class for new transactions
|
|
const isNew = newTransactions.some(ntx => String(ntx.hash || '') === hash);
|
|
const animationClass = isNew ? 'new-transaction' : '';
|
|
|
|
var fromClick = safeAddress(from) ? ' onclick="event.stopPropagation(); showAddressDetail(\'' + escapeHtml(from) + '\')" style="cursor: pointer;"' : '';
|
|
var toClick = safeAddress(to) ? ' onclick="event.stopPropagation(); showAddressDetail(\'' + escapeHtml(to) + '\')" style="cursor: pointer;"' : '';
|
|
html += '<tr class="' + animationClass + '" onclick="showTransactionDetail(\'' + escapeHtml(hash) + '\')" style="cursor: pointer;"><td class="hash">' + escapeHtml(shortenHash(hash)) + '</td><td class="hash"' + fromClick + '>' + formatAddressWithLabel(from) + '</td><td class="hash"' + toClick + '>' + (to ? formatAddressWithLabel(to) : '-') + '</td><td>' + escapeHtml(valueFormatted) + ' ETH</td><td>' + escapeHtml(String(blockNumber)) + '</td></tr>';
|
|
});
|
|
}
|
|
|
|
html += '</tbody></table>';
|
|
if (container) container.innerHTML = html;
|
|
|
|
if (container) {
|
|
setTimeout(() => {
|
|
container.querySelectorAll('.new-transaction').forEach(row => {
|
|
row.classList.remove('new-transaction');
|
|
});
|
|
}, 500);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load latest transactions:', error);
|
|
if (container) container.innerHTML = '<div class="error">Failed to load transactions: ' + escapeHtml((error.message || 'Unknown error').substring(0, 200)) + '. <button type="button" class="btn btn-primary" onclick="loadLatestTransactions()" style="margin-top: 0.5rem;">Retry</button></div>';
|
|
}
|
|
}
|
|
|
|
// Real-time transaction updates
|
|
function startTransactionUpdates() {
|
|
// Clear any existing interval
|
|
if (transactionUpdateInterval) {
|
|
clearInterval(transactionUpdateInterval);
|
|
}
|
|
|
|
// Update transactions every 5 seconds
|
|
transactionUpdateInterval = setInterval(() => {
|
|
if (currentView === 'home') {
|
|
loadLatestTransactions();
|
|
}
|
|
}, 5000);
|
|
}
|
|
|
|
function stopTransactionUpdates() {
|
|
if (transactionUpdateInterval) {
|
|
clearInterval(transactionUpdateInterval);
|
|
transactionUpdateInterval = null;
|
|
}
|
|
}
|
|
|
|
var blocksListPage = 1;
|
|
var transactionsListPage = 1;
|
|
var addressesListPage = 1;
|
|
const LIST_PAGE_SIZE = 25;
|
|
|
|
async function loadAllBlocks(page) {
|
|
if (page != null) blocksListPage = Math.max(1, parseInt(page, 10) || 1);
|
|
const container = document.getElementById('blocksList');
|
|
if (!container) { return; }
|
|
|
|
try {
|
|
container.innerHTML = '<div class="loading"><i class="fas fa-spinner"></i> Loading blocks...</div>';
|
|
|
|
let blocks = [];
|
|
|
|
if (CHAIN_ID === 138) {
|
|
blocks = await fetchChain138BlocksPage(blocksListPage, LIST_PAGE_SIZE);
|
|
} else {
|
|
// For other networks, use Etherscan-compatible API
|
|
const blockData = await fetchAPI(`${API_BASE}?module=block&action=eth_block_number`);
|
|
const latestBlock = parseInt(blockData.result, 16);
|
|
|
|
// Load last 50 blocks
|
|
for (let i = 0; i < 50 && latestBlock - i >= 0; i++) {
|
|
const blockNum = latestBlock - i;
|
|
try {
|
|
const block = await fetchAPI(`${API_BASE}?module=block&action=eth_get_block_by_number&tag=0x${blockNum.toString(16)}&boolean=false`);
|
|
if (block.result) {
|
|
blocks.push({
|
|
number: blockNum,
|
|
hash: block.result.hash,
|
|
timestamp: new Date(parseInt(block.result.timestamp, 16) * 1000).toISOString(),
|
|
transaction_count: block.result.transactions ? block.result.transactions.length : 0
|
|
});
|
|
}
|
|
} catch (e) {
|
|
// Skip failed blocks
|
|
}
|
|
}
|
|
}
|
|
|
|
const filter = getExplorerPageFilter('blocksList');
|
|
const filteredBlocks = filter ? blocks.filter(function(block) {
|
|
var d = normalizeBlockDisplay(block);
|
|
return matchesExplorerFilter([d.blockNum, d.hash, d.txCount, d.timestampFormatted, d.timeAgo].join(' '), filter);
|
|
}) : blocks;
|
|
const filterBar = renderPageFilterBar('blocksList', 'Filter blocks by number, hash, tx count, or age...', 'Filters the current page of blocks.', 'loadAllBlocks(' + blocksListPage + ')');
|
|
let html = filterBar + '<table class="table"><thead><tr><th>Block</th><th>Hash</th><th>Transactions</th><th>Timestamp</th></tr></thead><tbody>';
|
|
|
|
if (blocks.length === 0) {
|
|
html += '<tr><td colspan="4" style="text-align: center; padding: 2rem;">No blocks found</td></tr>';
|
|
} else if (filteredBlocks.length === 0) {
|
|
html += '<tr><td colspan="4" style="text-align: center; padding: 2rem;">No blocks match the current filter</td></tr>';
|
|
} else {
|
|
filteredBlocks.forEach(function(block) {
|
|
var d = normalizeBlockDisplay(block);
|
|
var blockNumber = escapeHtml(String(d.blockNum));
|
|
var blockHref = '/block/' + encodeURIComponent(String(d.blockNum));
|
|
var blockLink = '<a href="' + blockHref + '" onclick="event.preventDefault(); event.stopPropagation(); showBlockDetail(\'' + blockNumber + '\')" style="color: inherit; text-decoration: none; font-weight: 600;">' + blockNumber + '</a>';
|
|
var hashLink = safeBlockNumber(d.blockNum) ? '<a class="hash" href="' + blockHref + '" onclick="event.preventDefault(); event.stopPropagation(); showBlockDetail(\'' + blockNumber + '\')" style="color: inherit; text-decoration: none;">' + escapeHtml(shortenHash(d.hash)) + '</a>' : '<span class="hash">' + escapeHtml(shortenHash(d.hash)) + '</span>';
|
|
html += '<tr onclick="showBlockDetail(\'' + blockNumber + '\')" style="cursor: pointer;"><td>' + blockLink + '</td><td>' + hashLink + '</td><td>' + escapeHtml(String(d.txCount)) + '</td><td>' + escapeHtml(d.timestampFormatted) + '</td></tr>';
|
|
});
|
|
}
|
|
|
|
var pagination = '<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1rem; flex-wrap: wrap; gap: 0.5rem;">';
|
|
pagination += '<span style="color: var(--text-light);">Page ' + blocksListPage + '</span>';
|
|
pagination += '<div style="display: flex; gap: 0.5rem;"><button type="button" class="btn btn-secondary" ' + (blocksListPage <= 1 ? 'disabled' : '') + ' onclick="loadAllBlocks(' + (blocksListPage - 1) + ')">Prev</button><button type="button" class="btn btn-secondary" ' + (blocks.length < LIST_PAGE_SIZE ? 'disabled' : '') + ' onclick="loadAllBlocks(' + (blocksListPage + 1) + ')">Next</button></div></div>';
|
|
html += '</tbody></table>' + pagination;
|
|
container.innerHTML = html;
|
|
} catch (error) {
|
|
container.innerHTML = '<div class="error">Failed to load blocks: ' + escapeHtml(error.message || 'Unknown error') + '. <button onclick="showBlocks()" class="btn btn-primary" style="margin-top: 1rem;">Retry</button></div>';
|
|
}
|
|
}
|
|
|
|
async function loadAllTransactions(page) {
|
|
if (page != null) transactionsListPage = Math.max(1, parseInt(page, 10) || 1);
|
|
const container = document.getElementById('transactionsList');
|
|
if (!container) { return; }
|
|
|
|
try {
|
|
container.innerHTML = '<div class="loading"><i class="fas fa-spinner"></i> Loading transactions...</div>';
|
|
|
|
let transactions = [];
|
|
|
|
if (CHAIN_ID === 138) {
|
|
transactions = await fetchChain138TransactionsPage(transactionsListPage, LIST_PAGE_SIZE);
|
|
} else {
|
|
// For other networks, use Etherscan-compatible API
|
|
const blockData = await fetchAPI(`${API_BASE}?module=block&action=eth_block_number`);
|
|
if (!blockData || !blockData.result) {
|
|
throw new Error('Failed to get latest block number');
|
|
}
|
|
const latestBlock = parseInt(blockData.result, 16);
|
|
if (isNaN(latestBlock) || latestBlock < 0) {
|
|
throw new Error('Invalid block number');
|
|
}
|
|
|
|
const maxTxs = 50;
|
|
|
|
// Get transactions from recent blocks
|
|
for (let blockNum = latestBlock; blockNum >= 0 && transactions.length < maxTxs; blockNum--) {
|
|
try {
|
|
const block = await fetchAPI(`${API_BASE}?module=block&action=eth_get_block_by_number&tag=0x${blockNum.toString(16)}&boolean=true`);
|
|
if (block.result && block.result.transactions) {
|
|
for (const tx of block.result.transactions) {
|
|
if (transactions.length >= maxTxs) break;
|
|
if (typeof tx === 'object') {
|
|
transactions.push({
|
|
hash: tx.hash,
|
|
from: tx.from,
|
|
to: tx.to,
|
|
value: tx.value || '0',
|
|
block_number: blockNum
|
|
});
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
// Skip failed blocks
|
|
}
|
|
}
|
|
}
|
|
|
|
const filter = getExplorerPageFilter('transactionsList');
|
|
const filteredTransactions = filter ? transactions.filter(function(tx) {
|
|
const hash = String(tx.hash || '');
|
|
const from = String(tx.from || '');
|
|
const to = String(tx.to || '');
|
|
const blockNumber = String(tx.block_number || '');
|
|
const value = String(tx.value || '0');
|
|
return matchesExplorerFilter([hash, from, to, blockNumber, formatEther(value)].join(' '), filter);
|
|
}) : transactions;
|
|
const filterBar = renderPageFilterBar('transactionsList', 'Filter transactions by hash, address, block, or value...', 'Filters the current page of transactions.', 'loadAllTransactions(' + transactionsListPage + ')');
|
|
let html = filterBar + '<table class="table"><thead><tr><th>Hash</th><th>From</th><th>To</th><th>Value</th><th>Block</th></tr></thead><tbody>';
|
|
|
|
if (transactions.length === 0) {
|
|
html += '<tr><td colspan="5" style="text-align: center; padding: 2rem;">No transactions found</td></tr>';
|
|
} else if (filteredTransactions.length === 0) {
|
|
html += '<tr><td colspan="5" style="text-align: center; padding: 2rem;">No transactions match the current filter</td></tr>';
|
|
} else {
|
|
filteredTransactions.forEach(tx => {
|
|
const hash = String(tx.hash || 'N/A');
|
|
const from = String(tx.from || 'N/A');
|
|
const to = String(tx.to || 'N/A');
|
|
const value = tx.value || '0';
|
|
const blockNumber = tx.block_number || 'N/A';
|
|
const valueFormatted = formatEther(value);
|
|
var safeHash = escapeHtml(hash);
|
|
var txHref = '/tx/' + encodeURIComponent(hash);
|
|
var hashLink = '<a class="hash" href="' + txHref + '" onclick="event.preventDefault(); event.stopPropagation(); showTransactionDetail(\'' + safeHash + '\')" style="color: inherit; text-decoration: none;">' + escapeHtml(shortenHash(hash)) + '</a>';
|
|
var fromLink = safeAddress(from) ? '<a class="hash" href="/address/' + encodeURIComponent(from) + '" onclick="event.preventDefault(); event.stopPropagation(); showAddressDetail(\'' + escapeHtml(from) + '\')" style="color: inherit; text-decoration: none;">' + formatAddressWithLabel(from) + '</a>' : formatAddressWithLabel(from);
|
|
var toLink = safeAddress(to) ? '<a class="hash" href="/address/' + encodeURIComponent(to) + '" onclick="event.preventDefault(); event.stopPropagation(); showAddressDetail(\'' + escapeHtml(to || '') + '\')" style="color: inherit; text-decoration: none;">' + formatAddressWithLabel(to) + '</a>' : (to ? formatAddressWithLabel(to) : '-');
|
|
var blockLink = safeBlockNumber(blockNumber) ? '<a href="/block/' + encodeURIComponent(String(blockNumber)) + '" onclick="event.preventDefault(); event.stopPropagation(); showBlockDetail(\'' + escapeHtml(String(blockNumber)) + '\')" style="color: inherit; text-decoration: none;">' + escapeHtml(String(blockNumber)) + '</a>' : escapeHtml(String(blockNumber));
|
|
html += '<tr onclick="showTransactionDetail(\'' + safeHash + '\')" style="cursor: pointer;"><td>' + hashLink + '</td><td>' + fromLink + '</td><td>' + toLink + '</td><td>' + escapeHtml(valueFormatted) + ' ETH</td><td>' + blockLink + '</td></tr>';
|
|
});
|
|
}
|
|
|
|
var pagination = '<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1rem; flex-wrap: wrap; gap: 0.5rem;">';
|
|
pagination += '<span style="color: var(--text-light);">Page ' + transactionsListPage + '</span>';
|
|
pagination += '<div style="display: flex; gap: 0.5rem;"><button type="button" class="btn btn-secondary" ' + (transactionsListPage <= 1 ? 'disabled' : '') + ' onclick="loadAllTransactions(' + (transactionsListPage - 1) + ')">Prev</button><button type="button" class="btn btn-secondary" ' + (transactions.length < LIST_PAGE_SIZE ? 'disabled' : '') + ' onclick="loadAllTransactions(' + (transactionsListPage + 1) + ')">Next</button></div></div>';
|
|
html += '</tbody></table>' + pagination;
|
|
container.innerHTML = html;
|
|
} catch (error) {
|
|
container.innerHTML = '<div class="error">Failed to load transactions: ' + escapeHtml(error.message || 'Unknown error') + '. <button onclick="showTransactions()" class="btn btn-primary" style="margin-top: 1rem;">Retry</button></div>';
|
|
}
|
|
}
|
|
|
|
async function loadAllAddresses(page) {
|
|
if (page != null) addressesListPage = Math.max(1, parseInt(page, 10) || 1);
|
|
const container = document.getElementById('addressesList');
|
|
if (!container) { return; }
|
|
|
|
try {
|
|
container.innerHTML = '<div class="loading"><i class="fas fa-spinner"></i> Loading addresses...</div>';
|
|
|
|
const params = new URLSearchParams({
|
|
page: String(addressesListPage),
|
|
page_size: String(LIST_PAGE_SIZE)
|
|
});
|
|
const q = getExplorerPageFilter('addressesList');
|
|
if (q) params.set('q', q);
|
|
|
|
let addresses = [];
|
|
if (CHAIN_ID === 138) {
|
|
try {
|
|
const response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/addresses?${params.toString()}`);
|
|
const items = response && (response.items || response.data);
|
|
if (Array.isArray(items) && items.length > 0) {
|
|
addresses = items.map(normalizeAddress).filter(function(addr) { return addr !== null; });
|
|
}
|
|
} catch (blockscoutError) {
|
|
console.warn('Blockscout address list failed, trying explorer API:', blockscoutError.message || blockscoutError);
|
|
}
|
|
}
|
|
if (addresses.length === 0) {
|
|
const response = await fetchAPIWithRetry(`${API_BASE}/v1/addresses?${params.toString()}`);
|
|
const items = response && (response.data || response.items || []);
|
|
addresses = Array.isArray(items) ? items.map(normalizeAddress).filter(function(addr) { return addr !== null; }) : [];
|
|
}
|
|
|
|
const filter = getExplorerPageFilter('addressesList');
|
|
const filteredAddresses = filter ? addresses.filter(function(item) {
|
|
return matchesExplorerFilter([
|
|
item.address,
|
|
item.label || '',
|
|
item.is_contract ? 'contract' : 'externally owned',
|
|
item.tx_sent,
|
|
item.tx_received,
|
|
item.transaction_count,
|
|
item.token_count,
|
|
item.first_seen_at || '',
|
|
item.last_seen_at || ''
|
|
].join(' '), filter);
|
|
}) : addresses;
|
|
const filterBar = renderPageFilterBar('addressesList', 'Filter addresses by address, label, type, or activity...', 'Filters the indexed address list below.', 'loadAllAddresses(' + addressesListPage + ')');
|
|
let html = filterBar + '<table class="table"><thead><tr><th>Address</th><th>Label</th><th>Type</th><th>Tx Sent</th><th>Tx Received</th><th>Tokens</th><th>Last Seen</th></tr></thead><tbody>';
|
|
|
|
if (addresses.length === 0) {
|
|
html += '<tr><td colspan="7" style="text-align: center; padding: 2rem;">No addresses found</td></tr>';
|
|
} else if (filteredAddresses.length === 0) {
|
|
html += '<tr><td colspan="7" style="text-align: center; padding: 2rem;">No addresses match the current filter</td></tr>';
|
|
} else {
|
|
filteredAddresses.forEach(function(item) {
|
|
var addr = String(item.address || '');
|
|
var label = String(item.label || '');
|
|
var isContract = !!item.is_contract;
|
|
var type = isContract ? 'Contract' : 'EOA';
|
|
var txSent = Number(item.tx_sent || 0);
|
|
var txReceived = Number(item.tx_received || 0);
|
|
var tokenCount = Number(item.token_count || 0);
|
|
var lastSeen = String(item.last_seen_at || '—');
|
|
html += '<tr style="cursor: pointer;" onclick="showAddressDetail(\'' + escapeHtml(addr) + '\')">';
|
|
html += '<td><a class="hash" href="/address/' + encodeURIComponent(addr) + '" onclick="event.preventDefault(); event.stopPropagation(); showAddressDetail(\'' + escapeHtml(addr) + '\')" style="color: inherit; text-decoration: none;">' + escapeHtml(shortenHash(addr)) + '</a></td>';
|
|
html += '<td>' + escapeHtml(label || '—') + '</td>';
|
|
html += '<td>' + escapeHtml(type) + '</td>';
|
|
html += '<td>' + escapeHtml(String(txSent)) + '</td>';
|
|
html += '<td>' + escapeHtml(String(txReceived)) + '</td>';
|
|
html += '<td>' + escapeHtml(String(tokenCount)) + '</td>';
|
|
html += '<td>' + escapeHtml(lastSeen) + '</td>';
|
|
html += '</tr>';
|
|
});
|
|
}
|
|
|
|
var pagination = '<div style="display: flex; justify-content: space-between; align-items: center; margin-top: 1rem; flex-wrap: wrap; gap: 0.5rem;">';
|
|
pagination += '<span style="color: var(--text-light);">Page ' + addressesListPage + '</span>';
|
|
pagination += '<div style="display: flex; gap: 0.5rem;"><button type="button" class="btn btn-secondary" ' + (addressesListPage <= 1 ? 'disabled' : '') + ' onclick="loadAllAddresses(' + (addressesListPage - 1) + ')">Prev</button><button type="button" class="btn btn-secondary" ' + (addresses.length < LIST_PAGE_SIZE ? 'disabled' : '') + ' onclick="loadAllAddresses(' + (addressesListPage + 1) + ')">Next</button></div></div>';
|
|
html += '</tbody></table>' + pagination;
|
|
container.innerHTML = html;
|
|
} catch (error) {
|
|
container.innerHTML = '<div class="error">Failed to load addresses: ' + escapeHtml(error.message || 'Unknown error') + '. <button onclick="showAddresses()" class="btn btn-primary" style="margin-top: 1rem;">Retry</button></div>';
|
|
}
|
|
}
|
|
window._loadAllAddresses = loadAllAddresses;
|
|
window.loadAllAddresses = loadAllAddresses;
|
|
|
|
async function loadTokensList() {
|
|
var container = document.getElementById('tokensListContent');
|
|
if (!container) return;
|
|
try {
|
|
container.innerHTML = '<div class="loading"><i class="fas fa-spinner"></i> Loading tokens...</div>';
|
|
if (CHAIN_ID === 138) {
|
|
try {
|
|
var resp = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/tokens?page=1&page_size=100').catch(function() { return null; });
|
|
var items = (resp && (resp.items || resp.data)) || (Array.isArray(resp) ? resp : null);
|
|
if (items && items.length > 0) {
|
|
var knownTokens = {
|
|
'0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2': { name: 'Wrapped Ether', symbol: 'WETH' },
|
|
'0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f': { name: 'Wrapped Ether v10', symbol: 'WETH' }
|
|
};
|
|
var filter = getExplorerPageFilter('tokensList');
|
|
var filteredItems = filter ? items.filter(function(t) {
|
|
var addr = (t.address && (t.address.hash || t.address)) || t.address_hash || t.token_address || t.contract_address_hash || '';
|
|
var known = addr ? knownTokens[addr.toLowerCase()] : null;
|
|
var name = (known && known.name) || t.name || t.symbol || (known && known.symbol) || '-';
|
|
var symbolDisplay = (known && known.symbol) || t.symbol || '';
|
|
var type = t.type || 'ERC-20';
|
|
return matchesExplorerFilter([addr, name, symbolDisplay, type].join(' '), filter);
|
|
}) : items;
|
|
var filterBar = renderPageFilterBar('tokensList', 'Filter by token name, symbol, contract, or type...', 'Filters the indexed token list below.', 'loadTokensList()');
|
|
var html = filterBar + '<table class="table"><thead><tr><th>Token</th><th>Contract</th><th>Type</th><th aria-label="Add to wallet"></th></tr></thead><tbody>';
|
|
filteredItems.forEach(function(t) {
|
|
var addr = (t.address && (t.address.hash || t.address)) || t.address_hash || t.token_address || t.contract_address_hash || '';
|
|
var known = addr ? knownTokens[addr.toLowerCase()] : null;
|
|
var name = (known && known.name) || t.name || t.symbol || (known && known.symbol) || '-';
|
|
var symbolDisplay = (known && known.symbol) || t.symbol;
|
|
var symbol = (symbolDisplay || 'TOKEN').replace(/'/g, "\\'");
|
|
var decimals = t.decimals != null ? Number(t.decimals) : 18;
|
|
var type = t.type || 'ERC-20';
|
|
if (!addr) return;
|
|
var addrEsc = escapeHtml(addr).replace(/'/g, "\\'");
|
|
html += '<tr style="cursor: pointer;" onclick="showTokenDetail(\'' + escapeHtml(addr) + '\')"><td>' + escapeHtml(name) + (symbolDisplay ? ' (' + escapeHtml(symbolDisplay) + ')' : '') + '</td><td class="hash">' + escapeHtml(shortenHash(addr)) + '</td><td>' + escapeHtml(type) + '</td><td><button type="button" class="btn-add-token-wallet" onclick="event.stopPropagation(); window.addTokenToWallet && window.addTokenToWallet(\'' + addrEsc + '\', \'' + symbol + '\', ' + decimals + ');" aria-label="Add to wallet" title="Add to wallet"><i class="fas fa-wallet" aria-hidden="true"></i></button></td></tr>';
|
|
});
|
|
if (filteredItems.length === 0) {
|
|
html += '<tr><td colspan="4" style="text-align: center; padding: 1.5rem;">No tokens match the current filter.</td></tr>';
|
|
}
|
|
html += '</tbody></table>';
|
|
container.innerHTML = html;
|
|
return;
|
|
}
|
|
} catch (e) {}
|
|
}
|
|
container.innerHTML = renderPageFilterBar('tokensList', 'Filter by token name, symbol, contract, or type...', 'Filters the indexed token list below.', 'loadTokensList()') + '<p style="color: var(--text-light);">No token index available. Use the search bar to find tokens by name, symbol, or contract address (0x...).</p>';
|
|
} catch (err) {
|
|
container.innerHTML = renderPageFilterBar('tokensList', 'Filter by token name, symbol, contract, or type...', 'Filters the indexed token list below.', 'loadTokensList()') + '<div class="error">Failed to load tokens. Use the search bar to find a token by address or name.</div>';
|
|
}
|
|
}
|
|
window._loadTokensList = loadTokensList;
|
|
|
|
function normalizeRouteStatus(status) {
|
|
return status || 'unavailable';
|
|
}
|
|
|
|
function renderRouteMetric(label, value) {
|
|
return '<div style="display:flex; justify-content:space-between; gap:0.75rem; padding:0.35rem 0; border-bottom:1px solid var(--border);"><span style="color:var(--text-light);">' + escapeHtml(label) + '</span><strong>' + escapeHtml(value) + '</strong></div>';
|
|
}
|
|
|
|
function renderRouteNode(node, depthLevel) {
|
|
var indent = Math.max(0, depthLevel || 0) * 1.05;
|
|
var status = normalizeRouteStatus(node.status);
|
|
var statusColor = status === 'live' ? '#16a34a' : status === 'partial' ? '#f59e0b' : status === 'stale' ? '#d97706' : '#dc2626';
|
|
var html = '<div style="margin-left:' + indent.toFixed(2) + 'rem; padding:1rem; border:1px solid var(--border); border-left:4px solid ' + statusColor + '; border-radius:12px; background:var(--light);">';
|
|
html += '<div style="display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:0.75rem; margin-bottom:0.75rem;">';
|
|
html += '<div>';
|
|
html += '<div style="font-size:0.82rem; text-transform:uppercase; letter-spacing:0.06em; color:var(--text-light);">' + escapeHtml(node.kind || 'route') + '</div>';
|
|
html += '<div style="font-size:1rem; font-weight:700; margin-top:0.1rem;">' + escapeHtml(node.label || 'Untitled route') + '</div>';
|
|
html += '</div>';
|
|
html += '<span class="badge" style="background:' + statusColor + '; color:#fff; text-transform:uppercase;">' + escapeHtml(status) + '</span>';
|
|
html += '</div>';
|
|
html += '<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap:0.5rem; margin-bottom:0.75rem;">';
|
|
html += renderRouteMetric('Chain', (node.chainName || 'Chain') + ' (' + (node.chainId || '-') + ')');
|
|
if (node.dexType) html += renderRouteMetric('DEX', node.dexType);
|
|
if (node.poolAddress) html += renderRouteMetric('Pool', shortenHash(node.poolAddress));
|
|
if (node.depth) {
|
|
html += renderRouteMetric('TVL', '$' + Number(node.depth.tvlUsd || 0).toFixed(2));
|
|
html += renderRouteMetric('Capacity', '$' + Number(node.depth.estimatedTradeCapacityUsd || 0).toFixed(2));
|
|
html += renderRouteMetric('Freshness', node.depth.freshnessSeconds == null ? 'n/a' : node.depth.freshnessSeconds + 's');
|
|
}
|
|
html += '</div>';
|
|
if (node.notes && node.notes.length) {
|
|
html += '<ul style="margin:0 0 0.75rem 1.1rem; color:var(--text-light);">';
|
|
node.notes.forEach(function(note) {
|
|
html += '<li>' + escapeHtml(note) + '</li>';
|
|
});
|
|
html += '</ul>';
|
|
}
|
|
if (node.children && node.children.length) {
|
|
html += '<div style="display:grid; gap:0.75rem; margin-top:0.75rem; padding-left:0.35rem; border-left:1px dashed var(--border);">';
|
|
node.children.forEach(function(child) {
|
|
html += renderRouteNode(child, (depthLevel || 0) + 1);
|
|
});
|
|
html += '</div>';
|
|
}
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
function renderMissingQuotePools(missingPools) {
|
|
if (!missingPools || !missingPools.length) {
|
|
return '<div style="color:var(--text-light);">No quote-token metadata gaps detected in the current indexed pool set.</div>';
|
|
}
|
|
var html = '<div style="overflow-x:auto;"><table class="table"><thead><tr><th>Pool</th><th>Chain</th><th>Token 0</th><th>Token 1</th><th>Reason</th></tr></thead><tbody>';
|
|
missingPools.forEach(function(pool) {
|
|
html += '<tr>';
|
|
html += '<td class="hash">' + escapeHtml(shortenHash(pool.poolAddress)) + '</td>';
|
|
html += '<td>' + escapeHtml(String(pool.chainId)) + '</td>';
|
|
html += '<td>' + escapeHtml((pool.token0Symbol || '') + ' ' + shortenHash(pool.token0Address)) + '</td>';
|
|
html += '<td>' + escapeHtml((pool.token1Symbol || '') + ' ' + shortenHash(pool.token1Address)) + '</td>';
|
|
html += '<td>' + escapeHtml(pool.reason || 'Missing quote token metadata') + '</td>';
|
|
html += '</tr>';
|
|
});
|
|
html += '</tbody></table></div>';
|
|
return html;
|
|
}
|
|
|
|
function routeTreeQueryParams(query) {
|
|
var params = new URLSearchParams({
|
|
chainId: '138',
|
|
tokenIn: query.tokenIn,
|
|
amountIn: query.amountIn || '1000000'
|
|
});
|
|
if (query.tokenOut) params.set('tokenOut', query.tokenOut);
|
|
if (query.destinationChainId != null) params.set('destinationChainId', String(query.destinationChainId));
|
|
return params.toString();
|
|
}
|
|
|
|
async function fetchRouteTree(query) {
|
|
var response = await fetchAPIWithRetry(`${TOKEN_AGGREGATION_API_BASE}/v1/routes/tree?${routeTreeQueryParams(query)}`);
|
|
if ((!response || response.decision === 'unresolved' || !Array.isArray(response.tree) || response.tree.length === 0) &&
|
|
Number(query.destinationChainId || query.chainId || 138) === 138 &&
|
|
safeAddress(query.tokenOut)) {
|
|
try {
|
|
var ctx = await fetchCurrentPmmContext();
|
|
var liveFallback = await buildLiveDirectRouteFallback(query, ctx);
|
|
if (liveFallback) response = liveFallback;
|
|
} catch (e) {}
|
|
}
|
|
return { query: query, response: response };
|
|
}
|
|
|
|
function uniqueMissingQuotePools(results) {
|
|
var map = {};
|
|
results.forEach(function(entry) {
|
|
var pools = (entry.response && entry.response.missingQuoteTokenPools) || [];
|
|
pools.forEach(function(pool) {
|
|
var key = String(pool.chainId) + ':' + String(pool.poolAddress || '').toLowerCase();
|
|
if (!map[key]) map[key] = pool;
|
|
});
|
|
});
|
|
return Object.keys(map).sort().map(function(key) { return map[key]; });
|
|
}
|
|
|
|
function renderRouteSweepSummary(results) {
|
|
var html = '<div style="overflow-x:auto;"><table class="table"><thead><tr><th>Probe</th><th>Direct Pools</th><th>Missing Quote Pools</th><th>Decision</th><th>Freshest Status</th></tr></thead><tbody>';
|
|
results.forEach(function(entry) {
|
|
var response = entry.response || {};
|
|
var pools = Array.isArray(response.pools) ? response.pools : [];
|
|
var missing = Array.isArray(response.missingQuoteTokenPools) ? response.missingQuoteTokenPools : [];
|
|
var freshest = pools.length ? pools[0].depth && pools[0].depth.status ? pools[0].depth.status : 'unknown' : 'none';
|
|
html += '<tr>';
|
|
html += '<td>' + escapeHtml((entry.query.pairLabel || entry.query.title || entry.query.symbol || 'probe') + ' ' + shortenHash(entry.query.tokenIn)) + '</td>';
|
|
html += '<td>' + escapeHtml(String(pools.length)) + '</td>';
|
|
html += '<td>' + escapeHtml(String(missing.length)) + '</td>';
|
|
html += '<td>' + escapeHtml(response.decision || 'unresolved') + '</td>';
|
|
html += '<td>' + escapeHtml(freshest) + '</td>';
|
|
html += '</tr>';
|
|
});
|
|
html += '</tbody></table></div>';
|
|
return html;
|
|
}
|
|
|
|
function renderPriorityRouteCard(entry) {
|
|
var response = entry.response || {};
|
|
var rootNodes = Array.isArray(response.tree) ? response.tree : [];
|
|
var decisionLabel = escapeHtml(response.decision || 'unresolved');
|
|
if (response.decision === 'bridge-only' && response.destination && Number(response.destination.chainId) === 1) {
|
|
decisionLabel = 'bridge-only (destination completion on Mainnet)';
|
|
}
|
|
var html = '<div class="card" style="margin:0; box-shadow:none;">';
|
|
html += '<div class="card-header" style="align-items:flex-start; gap:0.75rem;">';
|
|
html += '<div>';
|
|
html += '<h3 class="card-title" style="margin-bottom:0.3rem;"><i class="fas fa-diagram-project"></i> ' + escapeHtml(entry.query.title) + '</h3>';
|
|
html += '<div style="color:var(--text-light); font-size:0.9rem;">Decision: <strong>' + decisionLabel + '</strong> | Generated: ' + escapeHtml((response.generatedAt || '').replace('T', ' ').replace('Z', ' UTC')) + '</div>';
|
|
html += '</div>';
|
|
html += '<button type="button" class="btn btn-secondary" onclick="loadLiveRouteTrees()" aria-label="Refresh live route tree"><i class="fas fa-sync-alt"></i> Refresh</button>';
|
|
html += '</div>';
|
|
html += '<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap:0.75rem; margin-bottom:1rem;">';
|
|
if (response.source) {
|
|
html += '<div style="padding:0.9rem; border:1px solid var(--border); border-radius:10px; background:var(--light);">';
|
|
html += '<div style="font-size:0.82rem; color:var(--text-light); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:0.35rem;">Source</div>';
|
|
html += '<div style="font-weight:700;">' + escapeHtml(response.source.chainName || 'Chain 138') + '</div>';
|
|
html += '<div style="color:var(--text-light); font-size:0.9rem; margin-top:0.35rem;">' + escapeHtml(response.source.tokenIn ? (response.source.tokenIn.symbol + ' ' + shortenHash(response.source.tokenIn.address)) : entry.query.title) + '</div>';
|
|
html += '</div>';
|
|
}
|
|
if (response.destination) {
|
|
html += '<div style="padding:0.9rem; border:1px solid var(--border); border-radius:10px; background:var(--light);">';
|
|
html += '<div style="font-size:0.82rem; color:var(--text-light); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:0.35rem;">Destination</div>';
|
|
html += '<div style="font-weight:700;">' + escapeHtml(response.destination.chainName || 'Destination') + '</div>';
|
|
html += '<div style="color:var(--text-light); font-size:0.9rem; margin-top:0.35rem;">Chain ' + escapeHtml(String(response.destination.chainId || '-')) + '</div>';
|
|
html += '</div>';
|
|
}
|
|
html += '<div style="padding:0.9rem; border:1px solid var(--border); border-radius:10px; background:var(--light);">';
|
|
html += '<div style="font-size:0.82rem; color:var(--text-light); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:0.35rem;">Coverage</div>';
|
|
html += '<div style="font-weight:700;">' + escapeHtml(String((response.pools || []).length)) + ' direct pool(s)</div>';
|
|
html += '<div style="color:var(--text-light); font-size:0.9rem; margin-top:0.35rem;">' + escapeHtml(String((response.missingQuoteTokenPools || []).length)) + ' missing quote-token pool(s)</div>';
|
|
html += '</div>';
|
|
html += '</div>';
|
|
if (rootNodes.length === 0) {
|
|
html += '<div style="color:var(--text-light);">No live route nodes available for this query yet.</div>';
|
|
} else {
|
|
rootNodes.slice(0, 4).forEach(function(node) {
|
|
html += renderRouteNode(node, 0);
|
|
});
|
|
}
|
|
html += '</div>';
|
|
return html;
|
|
}
|
|
|
|
function updatePoolsMissingQuoteBadge(count) {
|
|
var badge = document.getElementById('poolsMissingQuoteBadge');
|
|
if (!badge) return;
|
|
var n = Math.max(0, Number(count || 0));
|
|
badge.textContent = 'Missing quote routes ' + String(n);
|
|
badge.style.display = n > 0 ? 'inline-block' : 'none';
|
|
}
|
|
|
|
async function loadLiveRouteTrees(targetId) {
|
|
var containerId = targetId || (currentView === 'routes' ? 'routesRouteTreeContent' : 'poolRouteTreeContent');
|
|
var container = document.getElementById(containerId);
|
|
if (!container) return;
|
|
container.innerHTML = '<div class="loading"><i class="fas fa-spinner"></i> Loading live route tree...</div>';
|
|
|
|
try {
|
|
var ctx = await fetchCurrentPmmContext();
|
|
var priorityQueries = buildRoutePriorityQueries(ctx);
|
|
var sweepQueries = buildRouteSweepQueries(ctx);
|
|
var priorityResults = await Promise.allSettled(priorityQueries.map(fetchRouteTree));
|
|
var sweepResults = await Promise.allSettled(sweepQueries.map(fetchRouteTree));
|
|
var priorityOkResults = priorityResults.filter(function(result) { return result.status === 'fulfilled'; }).map(function(result) { return result.value; });
|
|
var priorityErrors = priorityResults.filter(function(result) { return result.status === 'rejected'; });
|
|
var sweepOkResults = sweepResults.filter(function(result) { return result.status === 'fulfilled'; }).map(function(result) { return result.value; });
|
|
var allSweepMissing = uniqueMissingQuotePools(sweepOkResults);
|
|
|
|
var html = '<div style="display:grid; gap:1rem;">';
|
|
html += '<div class="card" style="margin:0; box-shadow:none;">';
|
|
html += '<div class="card-header" style="align-items:flex-start; gap:0.75rem;">';
|
|
html += '<h3 class="card-title" style="margin-bottom:0;"><i class="fas fa-signal"></i> Route Coverage Sweep</h3>';
|
|
html += '<button type="button" class="btn btn-secondary" onclick="loadLiveRouteTrees()" aria-label="Refresh live route tree"><i class="fas fa-sync-alt"></i> Refresh all routes</button>';
|
|
html += '</div>';
|
|
html += '<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap:0.75rem; margin-bottom:1rem;">';
|
|
html += '<div style="padding:0.9rem; border:1px solid var(--border); border-radius:10px; background:var(--light);"><div style="font-size:0.82rem; color:var(--text-light); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:0.35rem;">Priority routes</div><div style="font-weight:700;">' + escapeHtml(String(priorityOkResults.length)) + ' ok' + (priorityErrors.length ? ' / ' + String(priorityErrors.length) + ' failed' : '') + '</div></div>';
|
|
html += '<div style="padding:0.9rem; border:1px solid var(--border); border-radius:10px; background:var(--light);"><div style="font-size:0.82rem; color:var(--text-light); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:0.35rem;">Sweep probes</div><div style="font-weight:700;">' + escapeHtml(String(sweepOkResults.length)) + ' ok</div></div>';
|
|
html += '<div style="padding:0.9rem; border:1px solid var(--border); border-radius:10px; background:var(--light);"><div style="font-size:0.82rem; color:var(--text-light); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:0.35rem;">Missing quote routes</div><div style="font-weight:700;">' + escapeHtml(String(allSweepMissing.length)) + '</div></div>';
|
|
html += '</div>';
|
|
html += '<div style="color:var(--text-light); margin-bottom:0.85rem; line-height:1.5;">This sweep probes explicit local token pairs against compliant and official anchor assets on Chain 138. The priority route cards above remain the bridge-path checks; this table focuses on direct-pair coverage and quote-token metadata gaps.</div>';
|
|
html += renderRouteSweepSummary(sweepOkResults);
|
|
if (priorityErrors.length) {
|
|
html += '<div style="margin-top:0.75rem; color:var(--text-light); font-size:0.9rem;">Some priority route requests failed, but the pools table is still available.</div>';
|
|
}
|
|
html += '</div>';
|
|
|
|
html += '<div style="display:grid; gap:1rem;">';
|
|
priorityOkResults.forEach(function(entry) {
|
|
html += renderPriorityRouteCard(entry);
|
|
});
|
|
html += '</div>';
|
|
|
|
html += '<div class="card" style="margin:0; box-shadow:none;">';
|
|
html += '<div class="card-header" style="align-items:flex-start; gap:0.75rem;">';
|
|
html += '<h3 class="card-title" style="margin-bottom:0;"><i class="fas fa-bug"></i> Missing Quote-Token Pools</h3>';
|
|
html += '</div>';
|
|
html += renderMissingQuotePools(allSweepMissing);
|
|
html += '</div>';
|
|
|
|
container.innerHTML = html;
|
|
updatePoolsMissingQuoteBadge(allSweepMissing.length);
|
|
} catch (err) {
|
|
container.innerHTML = '<div class="error">Failed to load live route tree: ' + escapeHtml(err.message || 'Unknown error') + '</div>';
|
|
updatePoolsMissingQuoteBadge(0);
|
|
}
|
|
}
|
|
|
|
function buildRoutesLandingHtml() {
|
|
var html = '';
|
|
html += '<div class="card" style="margin:0 0 1rem 0; box-shadow:none;">';
|
|
html += '<div class="card-header" style="align-items:flex-start; gap:0.75rem;">';
|
|
html += '<div>';
|
|
html += '<h3 class="card-title" style="margin-bottom:0.25rem;"><i class="fas fa-diagram-project"></i> Live Route Decision Tree</h3>';
|
|
html += '<div style="color:var(--text-light); line-height:1.5;">This dedicated view follows the Chain 138 routing graph end-to-end. It keeps the live coverage sweep, direct-pair diagnostics, and bridge-path branches together in one place so route investigations do not get buried inside the pools inventory.</div>';
|
|
html += '</div>';
|
|
html += '<div style="display:flex; gap:0.5rem; flex-wrap:wrap; margin-left:auto;">';
|
|
html += '<button type="button" class="btn btn-primary" onclick="loadLiveRouteTrees(\'routesRouteTreeContent\')" aria-label="Refresh live routes"><i class="fas fa-sync-alt"></i> Refresh</button>';
|
|
html += '<button type="button" class="btn btn-secondary" onclick="showPools(); updatePath(\'/pools\')" aria-label="Open pools inventory"><i class="fas fa-water"></i> Pools inventory</button>';
|
|
html += '<button type="button" class="btn btn-secondary" onclick="showLiquidityAccess(); updatePath(\'/liquidity\')" aria-label="Open liquidity access"><i class="fas fa-wave-square"></i> Liquidity access</button>';
|
|
html += '</div>';
|
|
html += '</div>';
|
|
html += '<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:0.75rem;">';
|
|
html += '<div style="padding:0.9rem; border:1px solid var(--border); border-radius:10px; background:var(--light);"><div style="font-size:0.82rem; color:var(--text-light); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:0.35rem;">Best for</div><div style="font-weight:700;">Route debugging and operator review</div><div style="color:var(--text-light); font-size:0.9rem; margin-top:0.35rem;">Use this page when a user route, destination branch, or quote-token path looks wrong.</div></div>';
|
|
html += '<div style="padding:0.9rem; border:1px solid var(--border); border-radius:10px; background:var(--light);"><div style="font-size:0.82rem; color:var(--text-light); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:0.35rem;">Includes</div><div style="font-weight:700;">Coverage sweep + priority route cards</div><div style="color:var(--text-light); font-size:0.9rem; margin-top:0.35rem;">The pools page now links here instead of embedding the full route tree inline.</div></div>';
|
|
html += '<div style="padding:0.9rem; border:1px solid var(--border); border-radius:10px; background:var(--light);"><div style="font-size:0.82rem; color:var(--text-light); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:0.35rem;">Data source</div><div style="font-weight:700;">Live token-aggregation route tree API</div><div style="color:var(--text-light); font-size:0.9rem; margin-top:0.35rem;">Every refresh re-reads current Chain 138 PMM and bridge state.</div></div>';
|
|
html += '</div>';
|
|
html += '</div>';
|
|
html += '<div id="routesRouteTreeContent"><div class="loading"><i class="fas fa-spinner"></i> Loading live route tree...</div></div>';
|
|
return html;
|
|
}
|
|
|
|
function renderRoutesView() {
|
|
showView('routes');
|
|
if ((window.location.pathname || '').replace(/^\//, '').split('/')[0] !== 'routes') updatePath('/routes');
|
|
updateBreadcrumb('routes');
|
|
var container = document.getElementById('routesContent');
|
|
if (!container) return;
|
|
container.innerHTML = buildRoutesLandingHtml();
|
|
setTimeout(function() {
|
|
loadLiveRouteTrees('routesRouteTreeContent');
|
|
}, 0);
|
|
_poolsRouteTreeRefreshTimer = setInterval(function() {
|
|
if (currentView === 'routes') {
|
|
loadLiveRouteTrees('routesRouteTreeContent');
|
|
}
|
|
}, ROUTE_TREE_REFRESH_MS);
|
|
}
|
|
window.renderRoutesView = renderRoutesView;
|
|
|
|
function summarizePoolRows(rows) {
|
|
var summary = {
|
|
liveLocal: 0,
|
|
externalMainnet: 0,
|
|
notYetCreated: 0,
|
|
missingCode: 0,
|
|
partial: 0,
|
|
};
|
|
(rows || []).forEach(function(row) {
|
|
var status = String((row && row.status) || '').toLowerCase();
|
|
if (status.indexOf('funded (live)') !== -1 || status.indexOf('deployed (live)') !== -1) {
|
|
summary.liveLocal += 1;
|
|
return;
|
|
}
|
|
if (status.indexOf('external / mainnet') !== -1 || status.indexOf('external / not on chain 138') !== -1) {
|
|
summary.externalMainnet += 1;
|
|
return;
|
|
}
|
|
if (status.indexOf('not created') !== -1) {
|
|
summary.notYetCreated += 1;
|
|
return;
|
|
}
|
|
if (status.indexOf('missing code') !== -1) {
|
|
summary.missingCode += 1;
|
|
return;
|
|
}
|
|
if (status.indexOf('partially funded') !== -1 || status.indexOf('created (unfunded)') !== -1) {
|
|
summary.partial += 1;
|
|
}
|
|
});
|
|
return summary;
|
|
}
|
|
|
|
function toCsv(rows) {
|
|
return rows.map(function(row) {
|
|
return row.map(function(cell) {
|
|
return '"' + String(cell == null ? '' : cell).replace(/"/g, '""') + '"';
|
|
}).join(',');
|
|
}).join('\n');
|
|
}
|
|
|
|
function exportPoolsCSV() {
|
|
if (!latestPoolsSnapshot || !Array.isArray(latestPoolsSnapshot.rows)) {
|
|
showToast('Pools data is not ready yet', 'error');
|
|
return;
|
|
}
|
|
var summary = latestPoolsSnapshot.summary || {};
|
|
var rows = latestPoolsSnapshot.rows || [];
|
|
var csvRows = [
|
|
['Section', 'Metric', 'Value'],
|
|
['Summary', 'Generated At', latestPoolsSnapshot.generatedAt || ''],
|
|
['Summary', 'Live local pools', summary.liveLocal || 0],
|
|
['Summary', 'External Mainnet-side', summary.externalMainnet || 0],
|
|
['Summary', 'Not yet created', summary.notYetCreated || 0],
|
|
['Summary', 'Needs attention', (summary.missingCode || 0) + (summary.partial || 0)],
|
|
[],
|
|
['Category', 'Pool Pair', 'System', 'Address', 'Status', 'Notes']
|
|
];
|
|
rows.forEach(function(row) {
|
|
csvRows.push([
|
|
row.category || '',
|
|
row.poolPair || '',
|
|
row.poolType || '',
|
|
row.address || '',
|
|
row.status || '',
|
|
row.notes || ''
|
|
]);
|
|
});
|
|
var blob = new Blob([toCsv(csvRows)], { type: 'text/csv' });
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'pools-status.csv';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
showToast('CSV downloaded', 'success');
|
|
}
|
|
|
|
function exportPoolsJSON() {
|
|
if (!latestPoolsSnapshot) {
|
|
showToast('Pools data is not ready yet', 'error');
|
|
return;
|
|
}
|
|
var blob = new Blob([JSON.stringify(latestPoolsSnapshot, null, 2)], { type: 'application/json' });
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'pools-status.json';
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
showToast('JSON downloaded', 'success');
|
|
}
|
|
window.exportPoolsCSV = exportPoolsCSV;
|
|
window.exportPoolsJSON = exportPoolsJSON;
|
|
|
|
async function renderPoolsView() {
|
|
showView('pools');
|
|
if ((window.location.pathname || '').replace(/^\//, '').split('/')[0] !== 'pools') updatePath('/pools');
|
|
var container = document.getElementById('poolsContent');
|
|
if (_poolsRouteTreeRefreshTimer) {
|
|
clearInterval(_poolsRouteTreeRefreshTimer);
|
|
_poolsRouteTreeRefreshTimer = null;
|
|
}
|
|
if (!container) return;
|
|
try {
|
|
container.innerHTML = '<div class="loading"><i class="fas fa-spinner"></i> Loading pools...</div>';
|
|
var live = await getLivePoolRows();
|
|
var filter = getExplorerPageFilter('poolsList');
|
|
var filterBar = renderPageFilterBar('poolsList', 'Filter by category, pair, type, status, address, or notes...', 'Tracks live Chain 138 pool, reserve, and bridge-linked contract state.', 'openPoolsView()');
|
|
var summary = summarizePoolRows(live.rows);
|
|
latestPoolsSnapshot = {
|
|
generatedAt: new Date().toISOString(),
|
|
summary: summary,
|
|
rows: live.rows
|
|
};
|
|
var rows = live.rows.map(function(row) {
|
|
return { row: row, searchText: [row.category, row.poolPair, row.poolType, row.address, row.status, row.notes].join(' ') };
|
|
});
|
|
var filtered = filter ? rows.filter(function(entry) { return matchesExplorerFilter(entry.searchText, filter); }) : rows;
|
|
var html = filterBar + '<div style="margin-bottom: 0.15rem; color: var(--text-light); font-size: 0.92rem; line-height: 1.4;">This table is derived from live Chain 138 contract state. Pool addresses, funding status, quote-token readiness, and private-registry registrations are refreshed from the chain each time the page renders. External or mainnet-only systems are labeled explicitly.</div>';
|
|
html += '<div style="display:grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap:0.75rem; margin:0.9rem 0 1rem;">';
|
|
html += '<div style="padding:0.9rem; border:1px solid var(--border); border-radius:10px; background:var(--light);"><div style="font-size:0.82rem; color:var(--text-light); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:0.35rem;">Live local pools</div><div style="font-weight:700;">' + escapeHtml(String(summary.liveLocal)) + '</div><div style="color:var(--text-light); font-size:0.9rem; margin-top:0.35rem;">Funded or deployed directly on Chain 138</div></div>';
|
|
html += '<div style="padding:0.9rem; border:1px solid var(--border); border-radius:10px; background:var(--light);"><div style="font-size:0.82rem; color:var(--text-light); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:0.35rem;">External Mainnet-side</div><div style="font-weight:700;">' + escapeHtml(String(summary.externalMainnet)) + '</div><div style="color:var(--text-light); font-size:0.9rem; margin-top:0.35rem;">Expected to live off Chain 138 by design</div></div>';
|
|
html += '<div style="padding:0.9rem; border:1px solid var(--border); border-radius:10px; background:var(--light);"><div style="font-size:0.82rem; color:var(--text-light); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:0.35rem;">Not yet created</div><div style="font-weight:700;">' + escapeHtml(String(summary.notYetCreated)) + '</div><div style="color:var(--text-light); font-size:0.9rem; margin-top:0.35rem;">No live pool mapping currently registered</div></div>';
|
|
html += '<div style="padding:0.9rem; border:1px solid var(--border); border-radius:10px; background:var(--light);"><div style="font-size:0.82rem; color:var(--text-light); text-transform:uppercase; letter-spacing:0.05em; margin-bottom:0.35rem;">Needs attention</div><div style="font-weight:700;">' + escapeHtml(String(summary.missingCode + summary.partial)) + '</div><div style="color:var(--text-light); font-size:0.9rem; margin-top:0.35rem;">Missing code, partial funding, or unfunded rows</div></div>';
|
|
html += '</div>';
|
|
html += '<div style="margin-top: 0.02rem;"><table class="table" style="margin-top: 0;"><thead><tr><th>Category</th><th>Pool Pair</th><th>System</th><th>Address</th><th>Status</th><th>Notes</th></tr></thead><tbody>';
|
|
if (rows.length === 0) {
|
|
html += '<tr><td colspan="6" style="text-align:center; padding: 1.5rem;">No pool data available yet.</td></tr>';
|
|
} else if (filtered.length === 0) {
|
|
html += '<tr><td colspan="6" style="text-align:center; padding: 1.5rem;">No pools match the current filter.</td></tr>';
|
|
} else {
|
|
filtered.forEach(function(entry) {
|
|
var row = entry.row;
|
|
var addr = row.address || '';
|
|
html += '<tr>';
|
|
html += '<td>' + escapeHtml(row.category) + '</td>';
|
|
html += '<td>' + escapeHtml(row.poolPair) + '</td>';
|
|
html += '<td>' + escapeHtml(row.poolType) + '</td>';
|
|
html += '<td>' + (safeAddress(addr) ? explorerAddressLink(addr, escapeHtml(shortenHash(addr)), 'color: inherit; text-decoration: none;') : '<span style="color: var(--text-light);">—</span>') + '</td>';
|
|
html += '<td>' + escapeHtml(row.status) + '</td>';
|
|
html += '<td>' + escapeHtml(row.notes) + '</td>';
|
|
html += '</tr>';
|
|
});
|
|
}
|
|
html += '</tbody></table></div>';
|
|
html += '<div class="card" style="margin-top:1.2rem; box-shadow:none;">';
|
|
html += '<div class="card-header" style="align-items:flex-start; gap:0.75rem;">';
|
|
html += '<div>';
|
|
html += '<h3 class="card-title" style="margin-bottom:0.25rem;"><i class="fas fa-diagram-project"></i> Live Route Decision Tree</h3>';
|
|
html += '<div style="color:var(--text-light); line-height:1.5;">The full route sweep and priority route cards now live on their own dedicated page so investigations can open directly into the routing graph.</div>';
|
|
html += '</div>';
|
|
html += '<div style="display:flex; gap:0.5rem; flex-wrap:wrap; margin-left:auto;">';
|
|
html += '<button type="button" class="btn btn-primary" onclick="showRoutes(); updatePath(\'/routes\')" aria-label="Open dedicated routes page"><i class="fas fa-arrow-right"></i> Open routes page</button>';
|
|
html += '<button type="button" class="btn btn-secondary" onclick="showLiquidityAccess(); updatePath(\'/liquidity\')" aria-label="Open liquidity access page"><i class="fas fa-wave-square"></i> Liquidity</button>';
|
|
html += '</div>';
|
|
html += '</div>';
|
|
html += '<div style="display:grid; gap:0.75rem;">';
|
|
html += '<div style="padding:0.9rem; border:1px solid var(--border); border-radius:10px; background:var(--light); color:var(--text-light); line-height:1.5;">Use <strong>Routes</strong> for the live route coverage sweep, bridge-path diagnostics, and missing quote-token review. The pools table above stays focused on pool inventory and funding state.</div>';
|
|
html += '<div style="display:flex; flex-wrap:wrap; gap:0.6rem;">';
|
|
html += '<a class="btn btn-secondary" href="/routes" onclick="event.preventDefault(); showRoutes(); updatePath(\'/routes\');" style="text-decoration:none;"><i class="fas fa-diagram-project"></i> Routes</a>';
|
|
html += '<a class="btn btn-secondary" href="/liquidity" onclick="event.preventDefault(); showLiquidityAccess(); updatePath(\'/liquidity\');" style="text-decoration:none;"><i class="fas fa-plug"></i> Public access points</a>';
|
|
html += '</div>';
|
|
html += '</div>';
|
|
html += '</div>';
|
|
container.innerHTML = html;
|
|
} catch (err) {
|
|
container.innerHTML = '<div class="error">Failed to load pools: ' + escapeHtml(err.message || 'Unknown error') + '</div>';
|
|
}
|
|
}
|
|
window.renderPoolsView = renderPoolsView;
|
|
|
|
function renderLiquidityAccessView() {
|
|
showView('liquidity');
|
|
if ((window.location.pathname || '').replace(/^\//, '').split('/')[0] !== 'liquidity') updatePath('/liquidity');
|
|
var container = document.getElementById('liquidityContent');
|
|
if (!container) return;
|
|
|
|
var publicApiBase = TOKEN_AGGREGATION_API_BASE + '/v1';
|
|
var livePools = [
|
|
{
|
|
pair: 'cUSDT / cUSDC',
|
|
poolAddress: '0xff8d3b8fDF7B112759F076B69f4271D4209C0849',
|
|
reserves: '10,000,000 / 10,000,000'
|
|
},
|
|
{
|
|
pair: 'cUSDT / USDT',
|
|
poolAddress: '0x6fc60DEDc92a2047062294488539992710b99D71',
|
|
reserves: '10,000,000 / 10,000,000'
|
|
},
|
|
{
|
|
pair: 'cUSDC / USDC',
|
|
poolAddress: '0x0309178ae30302D83c76d6Dd402a684eF3160eec',
|
|
reserves: '10,000,000 / 10,000,000'
|
|
},
|
|
{
|
|
pair: 'cUSDT / cXAUC',
|
|
poolAddress: '0x1AA55E2001E5651349AfF5A63FD7A7Ae44f0F1b0',
|
|
reserves: '2,666,965 / 519.477000'
|
|
},
|
|
{
|
|
pair: 'cUSDC / cXAUC',
|
|
poolAddress: '0xEA9Ac6357CaCB42a83b9082B870610363B177cBa',
|
|
reserves: '1,000,000 / 194.782554'
|
|
},
|
|
{
|
|
pair: 'cEURT / cXAUC',
|
|
poolAddress: '0xbA99bc1eAAC164569d5AcA96C806934DDaF970Cf',
|
|
reserves: '1,000,000 / 225.577676'
|
|
}
|
|
];
|
|
var endpointCards = [
|
|
{
|
|
title: 'Canonical route matrix',
|
|
method: 'GET',
|
|
href: publicApiBase + '/routes/matrix',
|
|
notes: 'Full live-route inventory with optional blocked and planned route visibility.'
|
|
},
|
|
{
|
|
title: 'Live ingestion export',
|
|
method: 'GET',
|
|
href: publicApiBase + '/routes/ingestion?fromChainId=138&routeType=swap',
|
|
notes: 'Flat export for adapter discovery and route ingestion.'
|
|
},
|
|
{
|
|
title: 'Partner payload templates',
|
|
method: 'GET',
|
|
href: publicApiBase + '/routes/partner-payloads?partner=0x&amount=1000000&includeUnsupported=true',
|
|
notes: 'Builds request templates for 1inch, 0x, and LiFi from live routes.'
|
|
},
|
|
{
|
|
title: 'Resolve supported partner payloads',
|
|
method: 'POST',
|
|
href: publicApiBase + '/routes/partner-payloads/resolve',
|
|
notes: 'Accepts partner, amount, and addresses and returns supported payloads by default.'
|
|
},
|
|
{
|
|
title: 'Dispatch supported partner payload',
|
|
method: 'POST',
|
|
href: publicApiBase + '/routes/partner-payloads/dispatch',
|
|
notes: 'Dispatches one supported partner payload when the chain is publicly supported.'
|
|
},
|
|
{
|
|
title: 'Internal Chain 138 execution plan',
|
|
method: 'POST',
|
|
href: publicApiBase + '/routes/internal-execution-plan',
|
|
notes: 'Returns the DODO PMM fallback execution plan when public partner support is unavailable.'
|
|
}
|
|
];
|
|
var requestExamples = [
|
|
'GET ' + publicApiBase + '/routes/matrix?includeNonLive=true',
|
|
'GET ' + publicApiBase + '/routes/ingestion?fromChainId=138&routeType=swap',
|
|
'GET ' + publicApiBase + '/routes/partner-payloads?partner=LiFi&amount=1000000&includeUnsupported=true',
|
|
'POST ' + publicApiBase + '/routes/partner-payloads/resolve',
|
|
'POST ' + publicApiBase + '/routes/internal-execution-plan'
|
|
];
|
|
|
|
var html = '';
|
|
html += '<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:0.9rem; margin-bottom:1rem;">';
|
|
html += '<div class="stat-card"><div class="stat-label">Live public pools</div><div class="stat-value">6</div><div style="color:var(--text-light); font-size:0.9rem; margin-top:0.4rem;">Verified DODO PMM pools on Chain 138.</div></div>';
|
|
html += '<div class="stat-card"><div class="stat-label">Public access path</div><div class="stat-value" style="font-size:1.2rem;">/token-aggregation/api/v1</div><div style="color:var(--text-light); font-size:0.9rem; margin-top:0.4rem;">Explorer-hosted proxy for route and execution APIs.</div></div>';
|
|
html += '<div class="stat-card"><div class="stat-label">Partner status</div><div class="stat-value">Fallback Ready</div><div style="color:var(--text-light); font-size:0.9rem; margin-top:0.4rem;">Templates exist for 1inch, 0x, and LiFi, but Chain 138 execution still falls back internally.</div></div>';
|
|
html += '</div>';
|
|
|
|
html += '<div class="card" style="margin-bottom:1rem; box-shadow:none;">';
|
|
html += '<div class="card-header"><h3 class="card-title"><i class="fas fa-water"></i> Live Pool Snapshot</h3><button type="button" class="btn btn-secondary" onclick="openPoolsView()" aria-label="Open pool operations view"><i class="fas fa-diagram-project"></i> Open pools view</button></div>';
|
|
html += '<div style="display:grid; gap:0.75rem;">';
|
|
livePools.forEach(function(pool) {
|
|
html += '<div style="padding:0.95rem; border:1px solid var(--border); border-radius:14px; background:var(--muted-surface);">';
|
|
html += '<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:0.75rem; flex-wrap:wrap;">';
|
|
html += '<div><div style="font-size:1rem; font-weight:700;">' + escapeHtml(pool.pair) + '</div><div style="margin-top:0.35rem; color:var(--text-light); font-size:0.85rem;">Pool: ' + escapeHtml(pool.poolAddress) + '</div></div>';
|
|
html += '<div style="font-size:0.92rem; font-weight:600;">Reserves: ' + escapeHtml(pool.reserves) + '</div>';
|
|
html += '</div></div>';
|
|
});
|
|
html += '</div></div>';
|
|
|
|
html += '<div class="card" style="margin-bottom:1rem; box-shadow:none;">';
|
|
html += '<div class="card-header"><h3 class="card-title"><i class="fas fa-network-wired"></i> Route and Execution Notes</h3></div>';
|
|
html += '<div style="display:grid; gap:0.75rem; color:var(--text-light); line-height:1.6;">';
|
|
html += '<div>Direct live routes today: cUSDT ↔ cUSDC, cUSDT ↔ USDT, cUSDC ↔ USDC, cUSDT ↔ cXAUC, cUSDC ↔ cXAUC, and cEURT ↔ cXAUC.</div>';
|
|
html += '<div>Multi-hop public paths exist through cXAUC for cEURT ↔ cUSDT, cEURT ↔ cUSDC, and an alternate cUSDT ↔ cUSDC path.</div>';
|
|
html += '<div>Mainnet bridge discovery is live for cUSDT → USDT and cUSDC → USDC through the configured UniversalCCIPBridge lane.</div>';
|
|
html += '<div>1inch, 0x, and LiFi request templates are available through the explorer API, but those partners do not publicly support Chain 138 execution today.</div>';
|
|
html += '<div>When public partner execution is unavailable, the internal DODO PMM execution plan endpoint returns the Chain 138 fallback route instead of a dead end.</div>';
|
|
html += '</div></div>';
|
|
|
|
html += '<div class="card" style="margin-bottom:1rem; box-shadow:none;">';
|
|
html += '<div class="card-header"><h3 class="card-title"><i class="fas fa-plug"></i> Public Explorer Access Points</h3></div>';
|
|
html += '<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(260px, 1fr)); gap:0.85rem;">';
|
|
endpointCards.forEach(function(card) {
|
|
html += '<a href="' + escapeAttr(card.href) + '" style="display:block; text-decoration:none; color:inherit; border:1px solid var(--border); border-radius:16px; padding:1rem; background:var(--muted-surface);">';
|
|
html += '<div style="display:flex; justify-content:space-between; align-items:flex-start; gap:0.75rem; margin-bottom:0.5rem;">';
|
|
html += '<div style="font-weight:700; line-height:1.35;">' + escapeHtml(card.title) + '</div>';
|
|
html += '<span class="badge badge-info" style="white-space:nowrap;">' + escapeHtml(card.method) + '</span>';
|
|
html += '</div>';
|
|
html += '<div style="font-size:0.82rem; color:var(--text-light); word-break:break-all; margin-bottom:0.5rem;">' + escapeHtml(card.href) + '</div>';
|
|
html += '<div style="font-size:0.9rem; color:var(--text-light); line-height:1.5;">' + escapeHtml(card.notes) + '</div>';
|
|
html += '</a>';
|
|
});
|
|
html += '</div></div>';
|
|
|
|
html += '<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(260px, 1fr)); gap:1rem;">';
|
|
html += '<div class="card" style="margin:0; box-shadow:none;"><div class="card-header"><h3 class="card-title"><i class="fas fa-terminal"></i> Quick Request Examples</h3></div><div style="display:grid; gap:0.75rem;">';
|
|
requestExamples.forEach(function(example) {
|
|
html += '<div style="padding:0.85rem; border:1px solid var(--border); border-radius:14px; background:var(--muted-surface);"><code style="display:block; font-size:0.82rem; line-height:1.6; word-break:break-all;">' + escapeHtml(example) + '</code></div>';
|
|
});
|
|
html += '</div></div>';
|
|
html += '<div class="card" style="margin:0; box-shadow:none;"><div class="card-header"><h3 class="card-title"><i class="fas fa-compass"></i> Related Explorer Tools</h3></div><div style="display:grid; gap:0.75rem; color:var(--text-light); line-height:1.6;">';
|
|
html += '<div>Use Wallet for network onboarding and the explorer token list URL, then open Routes for live route-tree diagnostics and Pools for contract-state inventory checks.</div>';
|
|
html += '<div style="display:flex; flex-wrap:wrap; gap:0.6rem; margin-top:0.2rem;">';
|
|
html += '<button type="button" class="btn btn-primary" onclick="showRoutes(); updatePath(\'/routes\')" aria-label="Open routes view"><i class="fas fa-diagram-project"></i> Routes view</button>';
|
|
html += '<button type="button" class="btn btn-secondary" onclick="showPools(); updatePath(\'/pools\')" aria-label="Open pools view"><i class="fas fa-water"></i> Pools view</button>';
|
|
html += '<button type="button" class="btn btn-secondary" onclick="showWETHUtilities(); updatePath(\'/weth\')" aria-label="Open WETH tools"><i class="fas fa-coins"></i> WETH tools</button>';
|
|
html += '<a class="btn btn-secondary" href="/docs.html" style="text-decoration:none;"><i class="fas fa-book"></i> Explorer docs</a>';
|
|
html += '</div></div></div>';
|
|
html += '</div>';
|
|
|
|
container.innerHTML = html;
|
|
}
|
|
window.renderLiquidityAccessView = renderLiquidityAccessView;
|
|
|
|
function renderMoreView() {
|
|
showView('more');
|
|
if ((window.location.pathname || '').replace(/^\//, '').split('/')[0] !== 'more') updatePath('/more');
|
|
var container = document.getElementById('moreContent');
|
|
if (!container) return;
|
|
var groups = [
|
|
{
|
|
key: 'tools',
|
|
title: 'Tools',
|
|
items: [
|
|
{ title: 'Input Data Decoder', icon: 'fa-file-code', status: 'Live', badgeClass: 'badge-info', desc: 'Open transaction detail pages to decode calldata, logs, and contract interactions already exposed by the explorer.', action: 'showTransactionsList();', href: '/transactions' },
|
|
{ title: 'Unit Converter', icon: 'fa-scale-balanced', status: 'Live', badgeClass: 'badge-success', desc: 'Convert wei, gwei, ether, and common Chain 138 stablecoin units with a quick in-page helper.', action: 'showUnitConverterModal();', href: '/more' },
|
|
{ title: 'CSV Export', icon: 'fa-file-csv', status: 'Live', badgeClass: 'badge-success', desc: 'Export pool state and route inventory snapshots for operator review and downstream ingestion.', action: 'showPools(); updatePath(\'/pools\'); setTimeout(function(){ if (typeof exportPoolsCSV === \"function\") exportPoolsCSV(); }, 200);', href: '/pools' },
|
|
{ title: 'Account Balance Checker', icon: 'fa-wallet', status: 'Live', badgeClass: 'badge-success', desc: 'Jump into indexed addresses to inspect balances, token inventory, internal transfers, and recent activity.', action: 'showAddresses();', href: '/addresses' }
|
|
]
|
|
},
|
|
{
|
|
key: 'explore',
|
|
title: 'Explore',
|
|
items: [
|
|
{ title: 'Gas Tracker', icon: 'fa-gas-pump', status: 'Live', badgeClass: 'badge-success', desc: 'Review live gas, block time, TPS, and chain health from the home network dashboard.', action: 'showHome();', href: '/' },
|
|
{ title: 'DEX Tracker', icon: 'fa-chart-line', status: 'Live', badgeClass: 'badge-success', desc: 'Open liquidity discovery, PMM pool status, live route trees, and partner payload access points.', action: 'showRoutes();', href: '/routes' },
|
|
{ title: 'Node Tracker', icon: 'fa-server', status: 'Live', badgeClass: 'badge-success', desc: 'Inspect bridge balances, destination configuration, and operator-facing chain references from the live bridge monitoring panel.', action: 'showBridgeMonitoring();', href: '/bridge' },
|
|
{ title: 'Label Cloud', icon: 'fa-tags', status: 'Live', badgeClass: 'badge-success', desc: 'Browse labeled addresses, contracts, and address activity through the explorer address index.', action: 'showAddresses();', href: '/addresses' },
|
|
{ title: 'Domain Name Lookup', icon: 'fa-magnifying-glass', status: 'Live', badgeClass: 'badge-success', desc: 'Use the smart search launcher to resolve ENS-style names, domains, addresses, hashes, and token symbols.', action: 'openSmartSearchModal(\'\');', href: '/more' }
|
|
]
|
|
},
|
|
{
|
|
key: 'services',
|
|
title: 'Services',
|
|
items: [
|
|
{ title: 'Token Approvals', icon: 'fa-shield-halved', status: 'External', badgeClass: 'badge-warning', desc: 'Jump to revoke.cash for wallet approval review. Address detail pages also expose approval shortcuts directly.', action: 'openExternalMoreLink(\'https://revoke.cash/\');', href: '#' },
|
|
{ title: 'Verified Signature', icon: 'fa-signature', status: 'Live', badgeClass: 'badge-success', desc: 'Use wallet sign-in and verified address flows already built into the explorer authentication surfaces.', action: 'showWalletModal();', href: '/more' },
|
|
{ title: 'Input Data Messages', icon: 'fa-message', status: 'Live', badgeClass: 'badge-info', desc: 'Transaction detail pages already surface decoded input data, event logs, and contract interaction context.', action: 'showTransactionsList();', href: '/transactions' },
|
|
{ title: 'Advanced Filter', icon: 'fa-filter', status: 'Live', badgeClass: 'badge-success', desc: 'Block, transaction, address, token, pool, bridge, and watchlist screens all support focused page-level filtering.', action: 'showTransactionsList();', href: '/transactions' },
|
|
{ title: 'MetaMask Snap', icon: 'fa-wallet', status: 'Live', badgeClass: 'badge-success', desc: 'Open the Chain 138 MetaMask Snap companion for network setup, token list access, and wallet integration guidance.', action: 'window.location.href=\'/snap/\';', href: '/snap/' }
|
|
]
|
|
}
|
|
];
|
|
|
|
var html = '<div style="display:grid; grid-template-columns:minmax(240px, 0.9fr) repeat(3, minmax(220px, 1fr)); gap:1rem; align-items:start;">';
|
|
html += '<div style="border:1px solid var(--border); border-radius:18px; padding:1.25rem; background:linear-gradient(180deg, rgba(59,130,246,0.08), rgba(15,23,42,0.02)); min-height:100%;">';
|
|
html += '<div style="font-size:1.25rem; font-weight:800; margin-bottom:0.75rem;">Tools & Services</div>';
|
|
html += '<div style="color:var(--text-light); line-height:1.7; margin-bottom:1rem;">Discover more of SolaceScanScout's explorer tools in one place, grouped the way users expect from Etherscan-style explorers.</div>';
|
|
html += '<div style="display:grid; gap:0.75rem;">';
|
|
html += '<div style="padding:0.85rem; border:1px solid var(--border); border-radius:14px; background:var(--muted-surface);"><div style="font-size:0.82rem; text-transform:uppercase; letter-spacing:0.08em; color:var(--text-light); margin-bottom:0.35rem;">Now live</div><div style="font-weight:700;">Route matrix, ingestion APIs, smart search, pool exports, and live Mainnet stable bridge discovery.</div></div>';
|
|
html += '<div style="padding:0.85rem; border:1px solid var(--border); border-radius:14px; background:var(--muted-surface);"><div style="font-size:0.82rem; text-transform:uppercase; letter-spacing:0.08em; color:var(--text-light); margin-bottom:0.35rem;">Good entry points</div><div style="display:flex; flex-wrap:wrap; gap:0.5rem;">';
|
|
html += '<button type="button" class="btn btn-primary" onclick="showRoutes(); updatePath(\'/routes\');"><i class="fas fa-diagram-project"></i> Routes</button>';
|
|
html += '<button type="button" class="btn btn-primary" onclick="showLiquidityAccess(); updatePath(\'/liquidity\');"><i class="fas fa-wave-square"></i> Liquidity</button>';
|
|
html += '<button type="button" class="btn btn-secondary" onclick="openSmartSearchModal(\'\');"><i class="fas fa-magnifying-glass"></i> Search</button>';
|
|
html += '<button type="button" class="btn btn-secondary" onclick="showAddresses(); updatePath(\'/addresses\');"><i class="fas fa-address-book"></i> Addresses</button>';
|
|
html += '</div></div>';
|
|
html += '</div></div>';
|
|
|
|
groups.forEach(function(group) {
|
|
html += '<div style="border:1px solid var(--border); border-radius:18px; padding:1.1rem; background:var(--light); min-height:100%;">';
|
|
html += '<div style="font-size:1.1rem; font-weight:800; margin-bottom:0.85rem;">' + escapeHtml(group.title) + '</div>';
|
|
html += '<div style="display:grid; gap:0.7rem;">';
|
|
group.items.forEach(function(item) {
|
|
var disabled = !!item.disabled;
|
|
var disabledTitle = String(item.title) + ' is not exposed in the explorer yet.';
|
|
var onclick = disabled
|
|
? ('event.preventDefault(); showToast(' + JSON.stringify(disabledTitle) + ', "info");')
|
|
: (item.href === '#'
|
|
? ('event.preventDefault(); ' + item.action + ' closeNavMenu();')
|
|
: ('event.preventDefault(); ' + item.action + ' updatePath(' + JSON.stringify(item.href) + '); closeNavMenu();'));
|
|
var href = disabled ? '/more' : item.href;
|
|
html += '<a href="' + escapeAttr(href) + '" onclick="' + onclick + '" style="display:block; text-decoration:none; color:inherit; border:1px solid var(--border); border-radius:14px; padding:0.9rem; background:' + (disabled ? 'rgba(148,163,184,0.08)' : 'var(--muted-surface)') + '; opacity:' + (disabled ? '0.78' : '1') + ';">';
|
|
html += '<div style="display:flex; justify-content:space-between; gap:0.75rem; align-items:flex-start; margin-bottom:0.45rem;">';
|
|
html += '<div style="display:flex; align-items:center; gap:0.65rem; min-width:0;">';
|
|
html += '<span style="width:2rem; height:2rem; border-radius:999px; display:inline-flex; align-items:center; justify-content:center; background:rgba(59,130,246,0.12); color:var(--primary); flex:0 0 auto;"><i class="fas ' + escapeHtml(item.icon) + '"></i></span>';
|
|
html += '<strong style="line-height:1.35;">' + escapeHtml(item.title) + '</strong>';
|
|
html += '</div>';
|
|
html += '<span class="badge ' + escapeHtml(item.badgeClass || 'badge-info') + '" style="white-space:nowrap;">' + escapeHtml(item.status) + '</span>';
|
|
html += '</div>';
|
|
html += '<div style="color:var(--text-light); font-size:0.9rem; line-height:1.55;">' + escapeHtml(item.desc) + '</div>';
|
|
html += '</a>';
|
|
});
|
|
html += '</div></div>';
|
|
});
|
|
html += '</div>';
|
|
container.innerHTML = html;
|
|
}
|
|
window._showMore = renderMoreView;
|
|
|
|
window.showUnitConverterModal = function() {
|
|
var existing = document.getElementById('unitConverterModal');
|
|
if (existing) existing.remove();
|
|
var modal = document.createElement('div');
|
|
modal.id = 'unitConverterModal';
|
|
modal.style.cssText = 'position:fixed; inset:0; background:rgba(8,15,32,0.68); backdrop-filter:blur(8px); z-index:12000; display:flex; align-items:center; justify-content:center; padding:1rem;';
|
|
modal.innerHTML = '' +
|
|
'<div style="width:min(560px, 100%); border-radius:18px; border:1px solid var(--border); background:var(--background); box-shadow:0 24px 90px rgba(0,0,0,0.35); overflow:hidden;">' +
|
|
'<div style="display:flex; justify-content:space-between; align-items:center; padding:1rem 1.1rem; border-bottom:1px solid var(--border);">' +
|
|
'<div><div style="font-size:1.1rem; font-weight:800;">Unit Converter</div><div style="color:var(--text-light); font-size:0.9rem; margin-top:0.2rem;">Wei, gwei, ether, and 6-decimal stablecoin units for Chain 138.</div></div>' +
|
|
'<button type="button" class="btn btn-secondary" id="unitConverterCloseBtn"><i class="fas fa-times"></i></button>' +
|
|
'</div>' +
|
|
'<div style="padding:1rem 1.1rem; display:grid; gap:0.9rem;">' +
|
|
'<label style="display:grid; gap:0.35rem;"><span style="font-weight:700;">Amount</span><input id="unitConverterAmount" type="number" min="0" step="any" placeholder="1.0" style="padding:0.8rem 0.9rem; border:1px solid var(--border); border-radius:12px; background:var(--light); color:var(--text);"></label>' +
|
|
'<label style="display:grid; gap:0.35rem;"><span style="font-weight:700;">Unit</span><select id="unitConverterUnit" style="padding:0.8rem 0.9rem; border:1px solid var(--border); border-radius:12px; background:var(--light); color:var(--text);"><option value="ether">Ether / WETH</option><option value="gwei">Gwei</option><option value="wei">Wei</option><option value="stable">Stablecoin (6 decimals)</option></select></label>' +
|
|
'<div id="unitConverterResults" style="display:grid; gap:0.55rem;"></div>' +
|
|
'</div>' +
|
|
'</div>';
|
|
document.body.appendChild(modal);
|
|
|
|
function renderUnitConverterResults() {
|
|
var amountEl = document.getElementById('unitConverterAmount');
|
|
var unitEl = document.getElementById('unitConverterUnit');
|
|
var resultsEl = document.getElementById('unitConverterResults');
|
|
if (!amountEl || !unitEl || !resultsEl) return;
|
|
var amount = Number(amountEl.value || '0');
|
|
var unit = unitEl.value;
|
|
if (!isFinite(amount) || amount < 0) {
|
|
resultsEl.innerHTML = '<div style="color:var(--text-light);">Enter a non-negative amount to convert.</div>';
|
|
return;
|
|
}
|
|
var wei = 0;
|
|
if (unit === 'ether') wei = amount * 1e18;
|
|
else if (unit === 'gwei') wei = amount * 1e9;
|
|
else if (unit === 'wei') wei = amount;
|
|
else if (unit === 'stable') wei = amount * 1e6;
|
|
var etherValue = unit === 'stable' ? 'N/A' : (wei / 1e18).toLocaleString(undefined, { maximumFractionDigits: 18 });
|
|
var gweiValue = unit === 'stable' ? 'N/A' : (wei / 1e9).toLocaleString(undefined, { maximumFractionDigits: 9 });
|
|
var stableValue = (unit === 'stable' ? amount : wei / 1e6).toLocaleString(undefined, { maximumFractionDigits: 6 });
|
|
resultsEl.innerHTML =
|
|
'<div style="padding:0.8rem; border:1px solid var(--border); border-radius:12px; background:var(--muted-surface);"><strong>Wei:</strong> ' + escapeHtml(Math.round(wei).toString()) + '</div>' +
|
|
'<div style="padding:0.8rem; border:1px solid var(--border); border-radius:12px; background:var(--muted-surface);"><strong>Gwei:</strong> ' + escapeHtml(gweiValue) + '</div>' +
|
|
'<div style="padding:0.8rem; border:1px solid var(--border); border-radius:12px; background:var(--muted-surface);"><strong>Ether / WETH:</strong> ' + escapeHtml(etherValue) + '</div>' +
|
|
'<div style="padding:0.8rem; border:1px solid var(--border); border-radius:12px; background:var(--muted-surface);"><strong>6-decimal stable amount:</strong> ' + escapeHtml(stableValue) + '</div>';
|
|
}
|
|
|
|
var closeBtn = document.getElementById('unitConverterCloseBtn');
|
|
if (closeBtn) closeBtn.addEventListener('click', function() { modal.remove(); });
|
|
modal.addEventListener('click', function(event) {
|
|
if (event.target === modal) modal.remove();
|
|
});
|
|
var amountEl = document.getElementById('unitConverterAmount');
|
|
var unitEl = document.getElementById('unitConverterUnit');
|
|
if (amountEl) amountEl.addEventListener('input', renderUnitConverterResults);
|
|
if (unitEl) unitEl.addEventListener('change', renderUnitConverterResults);
|
|
renderUnitConverterResults();
|
|
};
|
|
|
|
window.openExternalMoreLink = function(url) {
|
|
window.open(url, '_blank', 'noopener,noreferrer');
|
|
};
|
|
|
|
async function refreshBridgeData() {
|
|
const container = document.getElementById('bridgeContent');
|
|
if (!container) return;
|
|
|
|
try {
|
|
container.innerHTML = '<div class="loading"><i class="fas fa-spinner"></i> Loading bridge data...</div>';
|
|
|
|
// Chain 138 Bridge Contracts
|
|
const WETH9_BRIDGE_138 = '0x971cD9D156f193df8051E48043C476e53ECd4693';
|
|
const WETH10_BRIDGE_138 = '0xe0E93247376aa097dB308B92e6Ba36bA015535D0';
|
|
|
|
// Ethereum Mainnet Bridge Contracts
|
|
const WETH9_BRIDGE_MAINNET = '0x2A0840e5117683b11682ac46f5CF5621E67269E3';
|
|
const WETH10_BRIDGE_MAINNET = '0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03';
|
|
|
|
const explorerLinks = {
|
|
'BSC (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/' },
|
|
'Base (8453)': { label: 'BaseScan', baseUrl: 'https://basescan.org/address/' },
|
|
'Arbitrum (42161)': { label: 'Arbiscan', baseUrl: 'https://arbiscan.io/address/' },
|
|
'Optimism (10)': { label: 'Optimistic Etherscan', baseUrl: 'https://optimistic.etherscan.io/address/' },
|
|
'Ethereum Mainnet (1)': { label: 'Etherscan', baseUrl: 'https://etherscan.io/address/' }
|
|
};
|
|
function renderExplorerLink(chainLabel, address) {
|
|
const explorer = explorerLinks[chainLabel];
|
|
if (!explorer) return '<span style="color: var(--text-light);">No explorer</span>';
|
|
const url = explorer.baseUrl + address;
|
|
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 = {
|
|
weth9: {
|
|
'BSC (56)': '0x8078a09637e47fa5ed34f626046ea2094a5cde5e',
|
|
'Polygon (137)': '0xa780ef19a041745d353c9432f2a7f5a241335ffe',
|
|
'Avalanche (43114)': '0x8078a09637e47fa5ed34f626046ea2094a5cde5e',
|
|
'Base (8453)': '0x8078a09637e47fa5ed34f626046ea2094a5cde5e',
|
|
'Arbitrum (42161)': '0x8078a09637e47fa5ed34f626046ea2094a5cde5e',
|
|
'Optimism (10)': '0x8078a09637e47fa5ed34f626046ea2094a5cde5e',
|
|
'Ethereum Mainnet (1)': WETH9_BRIDGE_MAINNET
|
|
},
|
|
weth10: {
|
|
'BSC (56)': '0x105f8a15b819948a89153505762444ee9f324684',
|
|
'Polygon (137)': '0xdab0591e5e89295ffad75a71dcfc30c5625c4fa2',
|
|
'Avalanche (43114)': '0x105f8a15b819948a89153505762444ee9f324684',
|
|
'Base (8453)': '0x105f8a15b819948a89153505762444ee9f324684',
|
|
'Arbitrum (42161)': '0x105f8a15b819948a89153505762444ee9f324684',
|
|
'Optimism (10)': '0x105f8a15b819948a89153505762444ee9f324684',
|
|
'Ethereum Mainnet (1)': WETH10_BRIDGE_MAINNET
|
|
}
|
|
};
|
|
const sourceBridgeCount = 2;
|
|
const mainnetBridgeCount = 2;
|
|
const routeBridgeCount = new Set([
|
|
...Object.values(routes.weth9),
|
|
...Object.values(routes.weth10)
|
|
].map(function(addr) { return String(addr || '').toLowerCase(); })).size;
|
|
const totalBridgeCount = getActiveBridgeContractCount();
|
|
|
|
const bridgeFilter = getExplorerPageFilter('bridgeRoutes');
|
|
const filterBar = renderPageFilterBar('bridgeRoutes', 'Filter by chain name, chain ID, or bridge address...', 'Filters the route tables below.', 'refreshBridgeData()');
|
|
|
|
// Build HTML
|
|
let html = filterBar + `
|
|
<div class="bridge-chain-card">
|
|
<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
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Chain 138 Bridges -->
|
|
<div class="card" style="margin-bottom: 1.5rem;">
|
|
<div class="card-header">
|
|
<h3 class="card-title"><i class="fas fa-home"></i> Chain 138 (Source Chain)</h3>
|
|
<span class="badge badge-info" style="white-space: nowrap;">${sourceBridgeCount} source contracts / ${mainnetBridgeCount} mainnet contracts / ${routeBridgeCount} unique route contracts</span>
|
|
</div>
|
|
<div class="chain-info" style="grid-template-columns: repeat(auto-fit, minmax(300px, 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-value">
|
|
${explorerAddressLink(WETH9_BRIDGE_138, escapeHtml(WETH9_BRIDGE_138), 'color: inherit; text-decoration: none; font-size: 0.9rem;')}
|
|
</div>
|
|
<div style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--text-light);">
|
|
Token: ${explorerAddressLink('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', 'WETH9', 'color: inherit; text-decoration: none;')}
|
|
</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;">CCIPWETH10Bridge</div>
|
|
<div class="chain-stat-value">
|
|
${explorerAddressLink(WETH10_BRIDGE_138, escapeHtml(WETH10_BRIDGE_138), 'color: inherit; text-decoration: none; font-size: 0.9rem;')}
|
|
</div>
|
|
<div style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--text-light);">
|
|
Token: ${explorerAddressLink('0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f', 'WETH10', 'color: inherit; text-decoration: none;')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- WETH9 Bridge Routes -->
|
|
<div class="card" style="margin-bottom: 1.5rem;">
|
|
<div class="card-header">
|
|
<h3 class="card-title"><i class="fas fa-route"></i> CCIPWETH9Bridge Routes</h3>
|
|
<span class="badge badge-success">${Object.keys(routes.weth9).length} Destinations</span>
|
|
</div>
|
|
<div style="overflow-x: auto;">
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Destination Chain</th>
|
|
<th>Chain ID</th>
|
|
<th>Bridge Address</th>
|
|
<th>Explorer</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
`;
|
|
|
|
// Add WETH9 routes
|
|
const weth9Routes = Object.entries(routes.weth9).filter(function(entry) {
|
|
return !bridgeFilter || matchesExplorerFilter(entry[0] + ' ' + entry[1], bridgeFilter);
|
|
});
|
|
const weth10Routes = Object.entries(routes.weth10).filter(function(entry) {
|
|
return !bridgeFilter || matchesExplorerFilter(entry[0] + ' ' + entry[1], bridgeFilter);
|
|
});
|
|
if (weth9Routes.length === 0) {
|
|
html += '<tr><td colspan="4" style="text-align:center; padding: 1rem; color: var(--text-light);">No WETH9 routes match the current filter.</td></tr>';
|
|
}
|
|
for (const [chain, address] of weth9Routes) {
|
|
const chainId = chain.match(/\\((\d+)\\)/)?.[1] || '';
|
|
html += `
|
|
<tr>
|
|
<td><strong>${chain.replace(/\s*\\(\\d+\\)/, '')}</strong></td>
|
|
<td>${chainId}</td>
|
|
<td><span class="hash" onclick="showAddressDetail('${escapeHtml(address)}')" style="cursor: pointer;">${escapeHtml(shortenHash(address))}</span></td>
|
|
<td>${renderExplorerLink(chain, address)}</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
|
|
html += `
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- WETH10 Bridge Routes -->
|
|
<div class="card" style="margin-bottom: 1.5rem;">
|
|
<div class="card-header">
|
|
<h3 class="card-title"><i class="fas fa-route"></i> CCIPWETH10Bridge Routes</h3>
|
|
<span class="badge badge-success">${Object.keys(routes.weth10).length} Destinations</span>
|
|
</div>
|
|
<div style="overflow-x: auto;">
|
|
<table class="table">
|
|
<thead>
|
|
<tr>
|
|
<th>Destination Chain</th>
|
|
<th>Chain ID</th>
|
|
<th>Bridge Address</th>
|
|
<th>Explorer</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
`;
|
|
|
|
// Add WETH10 routes
|
|
if (weth10Routes.length === 0) {
|
|
html += '<tr><td colspan="4" style="text-align:center; padding: 1rem; color: var(--text-light);">No WETH10 routes match the current filter.</td></tr>';
|
|
}
|
|
for (const [chain, address] of weth10Routes) {
|
|
const chainId = chain.match(/\\((\d+)\\)/)?.[1] || '';
|
|
html += `
|
|
<tr>
|
|
<td><strong>${chain.replace(/\s*\\(\\d+\\)/, '')}</strong></td>
|
|
<td>${chainId}</td>
|
|
<td><span class="hash" onclick="showAddressDetail('${escapeHtml(address)}')" style="cursor: pointer;">${escapeHtml(shortenHash(address))}</span></td>
|
|
<td>${renderExplorerLink(chain, address)}</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
|
|
html += `
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Ethereum Mainnet Bridges -->
|
|
<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>
|
|
</div>
|
|
<div class="chain-info" style="grid-template-columns: repeat(auto-fit, minmax(300px, 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-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>
|
|
</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>
|
|
<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;">CCIPWETH10Bridge</div>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bridge Information -->
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h3 class="card-title"><i class="fas fa-info-circle"></i> Bridge Information</h3>
|
|
<span class="badge badge-info" style="white-space: nowrap;">${totalBridgeCount} Active Bridge Contracts</span>
|
|
</div>
|
|
<div style="line-height: 1.8;">
|
|
<p><strong>CCIP Bridge Ecosystem</strong> enables cross-chain transfers of WETH9 and WETH10 tokens using Chainlink CCIP (Cross-Chain Interoperability Protocol).</p>
|
|
|
|
<h4 style="margin-top: 1.5rem; margin-bottom: 0.5rem;">Supported Networks:</h4>
|
|
<ul style="margin-left: 2rem; margin-top: 0.5rem;">
|
|
<li><strong>Chain 138</strong> - Source chain with both bridge contracts</li>
|
|
<li><strong>Ethereum Mainnet</strong> - Destination with dedicated bridge contracts</li>
|
|
<li><strong>BSC</strong> - Binance Smart Chain</li>
|
|
<li><strong>Polygon</strong> - Polygon PoS</li>
|
|
<li><strong>Avalanche</strong> - Avalanche C-Chain</li>
|
|
<li><strong>Base</strong> - Base L2</li>
|
|
<li><strong>Arbitrum</strong> - Arbitrum One</li>
|
|
<li><strong>Optimism</strong> - Optimism Mainnet</li>
|
|
<li><strong>Cronos</strong> - Cronos (25); see routing table for full list and config-ready chains (Gnosis, Celo, Wemix)</li>
|
|
</ul>
|
|
|
|
<h4 style="margin-top: 1.5rem; margin-bottom: 0.5rem;">How to Use:</h4>
|
|
<ol style="margin-left: 2rem; margin-top: 0.5rem;">
|
|
<li>Click on any bridge address to view detailed information and transaction history</li>
|
|
<li>Use the bridge contracts to transfer WETH9 or WETH10 tokens between supported chains</li>
|
|
<li>All transfers are secured by Chainlink CCIP infrastructure</li>
|
|
</ol>
|
|
|
|
<h4 style="margin-top: 1.5rem; margin-bottom: 0.5rem;">CCIP Infrastructure:</h4>
|
|
<ul style="margin-left: 2rem; margin-top: 0.5rem;">
|
|
<li><strong>CCIP Router (Chain 138)</strong>: ${explorerAddressLink('0x8078A09637e47Fa5Ed34F626046Ea2094a5CDE5e', '0x8078A09637e47Fa5Ed34F626046Ea2094a5CDE5e', 'color: inherit; text-decoration: none;')}</li>
|
|
<li><strong>CCIP Sender (Chain 138)</strong>: ${explorerAddressLink('0x105F8A15b819948a89153505762444Ee9f324684', '0x105F8A15b819948a89153505762444Ee9f324684', 'color: inherit; text-decoration: none;')}</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
container.innerHTML = html;
|
|
} catch (error) {
|
|
container.innerHTML = '<div class="error">Failed to load bridge data: ' + escapeHtml(error.message) + '</div>';
|
|
}
|
|
}
|
|
|
|
function safeBlockNumber(v) { const n = String(v).replace(/[^0-9]/g, ''); return n ? n : null; }
|
|
function safeTxHash(v) { const s = String(v); return /^0x[a-fA-F0-9]{64}$/.test(s) ? s : null; }
|
|
function safeAddress(v) {
|
|
const s = String(v || '');
|
|
if (!/^0x[a-fA-F0-9]{40}$/i.test(s)) return null;
|
|
if (/^0x0{40}$/i.test(s)) return null;
|
|
return s;
|
|
}
|
|
function escapeJsSingleQuoted(value) {
|
|
return String(value == null ? '' : value).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
}
|
|
function explorerAddressLink(address, content, style) {
|
|
var safe = safeAddress(address);
|
|
if (!safe) return content || 'N/A';
|
|
return '<a class="hash" href="/address/' + encodeURIComponent(safe) + '" onclick="event.preventDefault(); event.stopPropagation(); showAddressDetail(\'' + escapeJsSingleQuoted(safe) + '\')" style="' + escapeAttr(style || 'color: inherit; text-decoration: none;') + '">' + (content || escapeHtml(shortenHash(safe))) + '</a>';
|
|
}
|
|
function explorerTransactionLink(txHash, content, style) {
|
|
var safe = safeTxHash(txHash);
|
|
if (!safe) return content || 'N/A';
|
|
return '<a class="hash" href="/tx/' + encodeURIComponent(safe) + '" onclick="event.preventDefault(); event.stopPropagation(); showTransactionDetail(\'' + escapeJsSingleQuoted(safe) + '\')" style="' + escapeAttr(style || 'color: inherit; text-decoration: none;') + '">' + (content || escapeHtml(shortenHash(safe))) + '</a>';
|
|
}
|
|
function explorerBlockLink(blockNumber, content, style) {
|
|
var safe = safeBlockNumber(blockNumber);
|
|
if (!safe) return content || 'N/A';
|
|
return '<a href="/block/' + encodeURIComponent(safe) + '" onclick="event.preventDefault(); event.stopPropagation(); showBlockDetail(\'' + escapeJsSingleQuoted(safe) + '\')" style="' + escapeAttr(style || 'color: inherit; text-decoration: none;') + '">' + (content || escapeHtml(String(safe))) + '</a>';
|
|
}
|
|
async function renderBlockDetail(blockNumber) {
|
|
const bn = safeBlockNumber(blockNumber);
|
|
if (!bn) { showToast('Invalid block number', 'error'); return; }
|
|
blockNumber = bn;
|
|
currentDetailKey = 'block:' + blockNumber;
|
|
showView('blockDetail');
|
|
updatePath('/block/' + blockNumber);
|
|
const container = document.getElementById('blockDetail');
|
|
updateBreadcrumb('block', blockNumber);
|
|
container.innerHTML = createSkeletonLoader('detail');
|
|
|
|
try {
|
|
let b;
|
|
|
|
// For ChainID 138, use Blockscout API directly
|
|
var rawBlockResponse = null;
|
|
if (CHAIN_ID === 138) {
|
|
try {
|
|
var detailResult = await fetchChain138BlockDetail(blockNumber);
|
|
rawBlockResponse = detailResult.rawBlockResponse;
|
|
b = detailResult.block;
|
|
if (!b) {
|
|
throw new Error('Block not found');
|
|
}
|
|
} catch (error) {
|
|
container.innerHTML = '<div class="error">Failed to load block: ' + escapeHtml(error.message || 'Unknown error') + '. <button onclick="showBlockDetail(\'' + escapeHtml(String(blockNumber)) + '\')" class="btn btn-primary" style="margin-top: 1rem;">Retry</button></div>';
|
|
return;
|
|
}
|
|
} else {
|
|
const block = await fetchAPIWithRetry(`${API_BASE}/v1/blocks/138/${blockNumber}`);
|
|
if (block.data) {
|
|
b = block.data;
|
|
} else {
|
|
throw new Error('Block not found');
|
|
}
|
|
}
|
|
|
|
if (b) {
|
|
const timestamp = new Date(b.timestamp).toLocaleString();
|
|
const gasUsedPercent = b.gas_limit ? ((parseInt(b.gas_used || 0) / parseInt(b.gas_limit)) * 100).toFixed(2) : '0';
|
|
const baseFeeGwei = b.base_fee_per_gas ? (parseInt(b.base_fee_per_gas) / 1e9).toFixed(2) : 'N/A';
|
|
const burntFeesEth = b.burnt_fees ? formatEther(b.burnt_fees) : '0';
|
|
|
|
container.innerHTML = `
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
|
<h2 style="margin: 0;">Block #${b.number}</h2>
|
|
<button onclick="exportBlockData(${b.number})" class="btn btn-primary" style="padding: 0.5rem 1rem;">
|
|
<i class="fas fa-download"></i> Export
|
|
</button>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Block Number</div>
|
|
<div class="info-value">${b.number}</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Hash</div>
|
|
<div class="info-value hash">${escapeHtml(b.hash || '')} <button type="button" class="btn-copy" onclick="event.stopPropagation(); copyToClipboard(\'' + String(b.hash || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'") + '\', \'Copied\');" aria-label="Copy hash"><i class="fas fa-copy"></i></button></div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Parent Hash</div>
|
|
<div class="info-value">${explorerBlockLink(String(parseInt(b.number) - 1), escapeHtml(b.parent_hash || ''), 'color: inherit; text-decoration: none;')}</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Timestamp</div>
|
|
<div class="info-value">${escapeHtml(timestamp)}</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Miner</div>
|
|
<div class="info-value">${explorerAddressLink(b.miner || '', formatAddressWithLabel(b.miner || '') || 'N/A', 'color: inherit; text-decoration: none;')}</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Transaction Count</div>
|
|
<div class="info-value">${b.transaction_count || 0}</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Gas Used</div>
|
|
<div class="info-value">${formatNumber(b.gas_used || 0)} / ${formatNumber(b.gas_limit || 0)} (${gasUsedPercent}%)</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Gas Limit</div>
|
|
<div class="info-value">${formatNumber(b.gas_limit || 0)}</div>
|
|
</div>
|
|
${b.base_fee_per_gas ? `
|
|
<div class="info-row">
|
|
<div class="info-label">Base Fee</div>
|
|
<div class="info-value">${baseFeeGwei} Gwei</div>
|
|
</div>
|
|
` : ''}
|
|
${b.burnt_fees && parseInt(b.burnt_fees) > 0 ? `
|
|
<div class="info-row">
|
|
<div class="info-label">Burnt Fees</div>
|
|
<div class="info-value">${burntFeesEth} ETH</div>
|
|
</div>
|
|
` : ''}
|
|
<div class="info-row">
|
|
<div class="info-label">Size</div>
|
|
<div class="info-value">${formatNumber(b.size || 0)} bytes</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Difficulty</div>
|
|
<div class="info-value">${formatNumber(b.difficulty || 0)}</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Nonce</div>
|
|
<div class="info-value">${escapeHtml(String(b.nonce || '0x0'))}</div>
|
|
</div>
|
|
${(rawBlockResponse && (rawBlockResponse.consensus !== undefined || rawBlockResponse.finality !== undefined || rawBlockResponse.validated !== undefined)) ? '<div class="info-row"><div class="info-label">Finality / Consensus</div><div class="info-value">' + escapeHtml(String(rawBlockResponse.consensus != null ? rawBlockResponse.consensus : (rawBlockResponse.finality != null ? rawBlockResponse.finality : rawBlockResponse.validated))) + '</div></div>' : ''}
|
|
`;
|
|
} else {
|
|
container.innerHTML = '<div class="error">Block not found</div>';
|
|
}
|
|
} catch (error) {
|
|
container.innerHTML = '<div class="error">Failed to load block: ' + escapeHtml(error.message) + '</div>';
|
|
}
|
|
}
|
|
window._showBlockDetail = renderBlockDetail;
|
|
// Keep wrapper (do not overwrite) so all calls go through setTimeout and avoid stack overflow
|
|
|
|
async function renderTransactionDetail(txHash) {
|
|
const th = safeTxHash(txHash);
|
|
if (!th) { showToast('Invalid transaction hash', 'error'); return; }
|
|
txHash = th;
|
|
currentDetailKey = 'tx:' + txHash.toLowerCase();
|
|
showView('transactionDetail');
|
|
updatePath('/tx/' + txHash);
|
|
const container = document.getElementById('transactionDetail');
|
|
updateBreadcrumb('transaction', txHash);
|
|
container.innerHTML = createSkeletonLoader('detail');
|
|
|
|
try {
|
|
let t;
|
|
let rawTx = null;
|
|
|
|
if (CHAIN_ID === 138) {
|
|
try {
|
|
var detailResult = await fetchChain138TransactionDetail(txHash);
|
|
rawTx = detailResult.rawTransaction;
|
|
t = detailResult.transaction;
|
|
if (!t) throw new Error('Transaction not found');
|
|
} catch (error) {
|
|
container.innerHTML = '<div class="error">Failed to load transaction: ' + escapeHtml(error.message || 'Unknown error') + '. <button onclick="showTransactionDetail(\'' + escapeHtml(String(txHash)) + '\')" class="btn btn-primary" style="margin-top: 1rem;">Retry</button></div>';
|
|
return;
|
|
}
|
|
} else {
|
|
const tx = await fetchAPIWithRetry(`${API_BASE}/v1/transactions/138/${txHash}`);
|
|
if (tx.data) t = tx.data;
|
|
else throw new Error('Transaction not found');
|
|
}
|
|
|
|
if (!t) {
|
|
container.innerHTML = '<div class="error">Transaction not found</div>';
|
|
return;
|
|
}
|
|
|
|
const timestamp = new Date(t.created_at).toLocaleString();
|
|
const valueEth = formatEther(t.value || '0');
|
|
const gasPriceGwei = t.gas_price ? (parseInt(t.gas_price) / 1e9).toFixed(2) : 'N/A';
|
|
const maxFeeGwei = t.max_fee_per_gas ? (parseInt(t.max_fee_per_gas) / 1e9).toFixed(2) : 'N/A';
|
|
const priorityFeeGwei = t.max_priority_fee_per_gas ? (parseInt(t.max_priority_fee_per_gas) / 1e9).toFixed(2) : 'N/A';
|
|
const burntFeeEth = t.tx_burnt_fee ? formatEther(t.tx_burnt_fee) : '0';
|
|
const totalFee = t.gas_used && t.gas_price ? formatEther((BigInt(t.gas_used) * BigInt(t.gas_price)).toString()) : '0';
|
|
const txType = t.type === 2 ? 'EIP-1559' : t.type === 1 ? 'EIP-2930' : 'Legacy';
|
|
const revertReason = t.revert_reason || (rawTx && (rawTx.revert_reason || rawTx.error || rawTx.result));
|
|
const inputHex = (t.input && t.input !== '0x') ? t.input : null;
|
|
const decodedInput = t.decoded_input || (rawTx && rawTx.decoded_input);
|
|
const toCellContent = t.to ? explorerAddressLink(t.to, formatAddressWithLabel(t.to), 'color: inherit; text-decoration: none;') + ' <button type="button" class="btn-copy" onclick="event.stopPropagation(); copyToClipboard(\'' + String(t.to).replace(/\\/g, '\\\\').replace(/'/g, "\\'") + '\', \'Copied\');" aria-label="Copy address"><i class="fas fa-copy"></i></button>' : 'N/A';
|
|
|
|
let mainHtml = `
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
|
|
<h2 style="margin: 0;">Transaction</h2>
|
|
<div style="display: flex; gap: 0.5rem;">
|
|
<button onclick="exportTransactionData('${t.hash}')" class="btn btn-primary" style="padding: 0.5rem 1rem;">
|
|
<i class="fas fa-download"></i> JSON
|
|
</button>
|
|
<button onclick="exportTransactionCSV('${t.hash}')" class="btn btn-primary" style="padding: 0.5rem 1rem;">
|
|
<i class="fas fa-file-csv"></i> CSV
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Transaction Hash</div>
|
|
<div class="info-value hash">${escapeHtml(t.hash)} <button type="button" class="btn-copy" onclick="copyToClipboard('${escapeHtml(t.hash)}', 'Copied');" aria-label="Copy hash"><i class="fas fa-copy"></i></button></div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Type</div>
|
|
<div class="info-value"><span class="badge badge-primary">${txType}</span></div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Status</div>
|
|
<div class="info-value">
|
|
<span class="badge ${t.status === 1 ? 'badge-success' : 'badge-danger'}">
|
|
${t.status === 1 ? 'Success' : t.status === 0 ? 'Failed' : 'Pending'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Block Number</div>
|
|
<div class="info-value">${explorerBlockLink(String(t.block_number || ''), escapeHtml(String(t.block_number || 'N/A')), 'color: inherit; text-decoration: none;')}</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Block Hash</div>
|
|
<div class="info-value hash">${escapeHtml(t.block_hash || 'N/A')}</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">From</div>
|
|
<div class="info-value">${explorerAddressLink(t.from || '', formatAddressWithLabel(t.from || ''), 'color: inherit; text-decoration: none;')} <button type="button" class="btn-copy" onclick="event.stopPropagation(); copyToClipboard('${escapeHtml(t.from || '')}', 'Copied');" aria-label="Copy address"><i class="fas fa-copy"></i></button></div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">To</div>
|
|
<div class="info-value">${toCellContent}</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Value</div>
|
|
<div class="info-value">${valueEth} ETH</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Gas Used</div>
|
|
<div class="info-value">${t.gas_used ? formatNumber(t.gas_used) : 'N/A'}</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Gas Limit</div>
|
|
<div class="info-value">${t.gas_limit ? formatNumber(t.gas_limit) : 'N/A'}</div>
|
|
</div>
|
|
${t.max_fee_per_gas ? `<div class="info-row"><div class="info-label">Max Fee Per Gas</div><div class="info-value">${maxFeeGwei} Gwei</div></div>` : ''}
|
|
${t.max_priority_fee_per_gas ? `<div class="info-row"><div class="info-label">Max Priority Fee</div><div class="info-value">${priorityFeeGwei} Gwei</div></div>` : ''}
|
|
${!t.max_fee_per_gas && t.gas_price ? `<div class="info-row"><div class="info-label">Gas Price</div><div class="info-value">${gasPriceGwei} Gwei</div></div>` : ''}
|
|
<div class="info-row">
|
|
<div class="info-label">Total Fee</div>
|
|
<div class="info-value">${totalFee} ETH</div>
|
|
</div>
|
|
${t.tx_burnt_fee && parseInt(t.tx_burnt_fee) > 0 ? `<div class="info-row"><div class="info-label">Burnt Fee</div><div class="info-value">${burntFeeEth} ETH</div></div>` : ''}
|
|
<div class="info-row"><div class="info-label">Nonce</div><div class="info-value">${t.nonce || 'N/A'}</div></div>
|
|
<div class="info-row"><div class="info-label">Timestamp</div><div class="info-value">${timestamp}</div></div>
|
|
${t.contract_address ? `<div class="info-row"><div class="info-label">Contract Address</div><div class="info-value">${explorerAddressLink(t.contract_address, escapeHtml(t.contract_address), 'color: inherit; text-decoration: none;')}</div></div>` : ''}
|
|
`;
|
|
|
|
if (revertReason && t.status !== 1) {
|
|
const reasonStr = typeof revertReason === 'string' ? revertReason : (revertReason.message || JSON.stringify(revertReason));
|
|
mainHtml += `
|
|
<div class="card" style="margin-top: 1rem; border-left: 4px solid var(--danger);">
|
|
<h3 style="color: var(--danger); margin-bottom: 0.5rem;"><i class="fas fa-exclamation-triangle"></i> Revert Reason</h3>
|
|
<pre style="background: var(--light); padding: 1rem; border-radius: 8px; overflow-x: auto; font-size: 0.875rem;">${escapeHtml(reasonStr)}</pre>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
if (inputHex || decodedInput) {
|
|
mainHtml += `<div class="card" style="margin-top: 1rem;"><h3>Input Data</h3>`;
|
|
if (decodedInput && (decodedInput.method || decodedInput.params)) {
|
|
const method = decodedInput.method || decodedInput.name || 'Unknown';
|
|
mainHtml += `<p style="margin-bottom: 0.5rem;"><strong>Method:</strong> ${escapeHtml(method)}</p>`;
|
|
if (decodedInput.params && Array.isArray(decodedInput.params)) {
|
|
mainHtml += '<table class="table"><thead><tr><th>Param</th><th>Value</th></tr></thead><tbody>';
|
|
decodedInput.params.forEach(function(p) {
|
|
const name = (p.name || p.type || '');
|
|
const val = typeof p.value !== 'undefined' ? String(p.value) : (p.type || '');
|
|
mainHtml += '<tr><td>' + escapeHtml(name) + '</td><td class="hash" style="word-break: break-all;">' + escapeHtml(val) + '</td></tr>';
|
|
});
|
|
mainHtml += '</tbody></table>';
|
|
}
|
|
}
|
|
if (inputHex) {
|
|
mainHtml += `<p style="margin-top: 0.5rem;"><strong>Hex:</strong> <code id="txInputHex" style="word-break: break-all; font-size: 0.8rem;">${escapeHtml(inputHex)}</code> <button type="button" class="btn btn-primary" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;" onclick="navigator.clipboard.writeText(document.getElementById(\'txInputHex\').textContent); showToast(\'Copied\', \'success\');">Copy</button></p>`;
|
|
}
|
|
mainHtml += '</div>';
|
|
}
|
|
|
|
container.innerHTML = mainHtml;
|
|
|
|
if (CHAIN_ID === 138) {
|
|
const internalCard = document.createElement('div');
|
|
internalCard.className = 'card';
|
|
internalCard.style.marginTop = '1rem';
|
|
internalCard.innerHTML = '<h3><i class="fas fa-sitemap"></i> Internal Transactions</h3><div id="txInternalTxs" class="loading">Loading...</div>';
|
|
container.appendChild(internalCard);
|
|
|
|
const logsCard = document.createElement('div');
|
|
logsCard.className = 'card';
|
|
logsCard.style.marginTop = '1rem';
|
|
logsCard.innerHTML = '<h3><i class="fas fa-list"></i> Event Logs</h3><div id="txLogs" class="loading">Loading...</div>';
|
|
container.appendChild(logsCard);
|
|
|
|
Promise.all([
|
|
fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/transactions/${txHash}/internal-transactions`).catch(function() { return { items: [] }; }),
|
|
fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/transactions/${txHash}/internal_transactions`).catch(function() { return { items: [] }; }),
|
|
fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/transactions/${txHash}/logs`).catch(function() { return { items: [] }; }),
|
|
fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/transactions/${txHash}/log_entries`).catch(function() { return { items: [] }; })
|
|
]).then(function(results) {
|
|
const internalResp = results[0].items ? results[0] : results[1];
|
|
const logsResp = results[2].items ? results[2] : results[3];
|
|
const internals = internalResp.items || [];
|
|
const logs = logsResp.items || logsResp.log_entries || [];
|
|
|
|
const internalEl = document.getElementById('txInternalTxs');
|
|
if (internalEl) {
|
|
const internalFilter = getExplorerPageFilter('txInternalTxs');
|
|
const reloadInternalJs = 'showTransactionDetail(\'' + txHash.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + '\')';
|
|
const internalFilterBar = renderPageFilterBar('txInternalTxs', 'Filter by type, from, to, or value...', 'Filters the internal transactions below.', reloadInternalJs);
|
|
if (internals.length === 0) {
|
|
internalEl.innerHTML = internalFilterBar + '<p style="color: var(--text-light);">No internal transactions</p>';
|
|
} else {
|
|
const filteredInternals = internalFilter ? internals.filter(function(it) {
|
|
const from = it.from?.hash || it.from || 'N/A';
|
|
const to = it.to?.hash || it.to || 'N/A';
|
|
const val = it.value ? formatEther(it.value) : '0';
|
|
const type = it.type || it.call_type || 'call';
|
|
return matchesExplorerFilter([type, from, to, val].join(' '), internalFilter);
|
|
}) : internals;
|
|
let tbl = internalFilterBar + '<table class="table"><thead><tr><th>Type</th><th>From</th><th>To</th><th>Value</th></tr></thead><tbody>';
|
|
filteredInternals.forEach(function(it) {
|
|
const from = it.from?.hash || it.from || 'N/A';
|
|
const to = it.to?.hash || it.to || 'N/A';
|
|
const val = it.value ? formatEther(it.value) : '0';
|
|
const type = it.type || it.call_type || 'call';
|
|
tbl += '<tr><td>' + escapeHtml(type) + '</td><td>' + explorerAddressLink(from, escapeHtml(shortenHash(from)), 'color: inherit; text-decoration: none;') + '</td><td>' + explorerAddressLink(to, escapeHtml(shortenHash(to)), 'color: inherit; text-decoration: none;') + '</td><td>' + escapeHtml(val) + ' ETH</td></tr>';
|
|
});
|
|
if (filteredInternals.length === 0) {
|
|
tbl += '<tr><td colspan="4" style="text-align:center; padding: 1rem;">No internal transactions match the current filter.</td></tr>';
|
|
}
|
|
tbl += '</tbody></table>';
|
|
internalEl.innerHTML = tbl;
|
|
}
|
|
}
|
|
|
|
const logsEl = document.getElementById('txLogs');
|
|
if (logsEl) {
|
|
const logsFilter = getExplorerPageFilter('txLogs');
|
|
const reloadLogsJs = 'showTransactionDetail(\'' + txHash.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + '\')';
|
|
const logsFilterBar = renderPageFilterBar('txLogs', 'Filter by address, topics, data, or decoded text...', 'Filters the event logs below.', reloadLogsJs);
|
|
if (logs.length === 0) {
|
|
logsEl.innerHTML = logsFilterBar + '<p style="color: var(--text-light);">No event logs</p>';
|
|
} else {
|
|
const filteredLogs = logsFilter ? logs.filter(function(log) {
|
|
const addr = log.address?.hash || log.address || 'N/A';
|
|
const topics = (log.topics && Array.isArray(log.topics)) ? log.topics : (log.topic0 ? [log.topic0] : []);
|
|
const topicsStr = topics.join(', ');
|
|
const data = log.data || log.raw_data || '0x';
|
|
const decoded = log.decoded || log.decoded_text || '';
|
|
return matchesExplorerFilter([addr, topicsStr, data, decoded].join(' '), logsFilter);
|
|
}) : logs;
|
|
let tbl = logsFilterBar + '<table class="table"><thead><tr><th>Address</th><th>Topics</th><th>Data</th><th>Decoded</th></tr></thead><tbody>';
|
|
filteredLogs.forEach(function(log, idx) {
|
|
const addr = log.address?.hash || log.address || 'N/A';
|
|
const topics = (log.topics && Array.isArray(log.topics)) ? log.topics : (log.topic0 ? [log.topic0] : []);
|
|
const topicsStr = topics.join(', ');
|
|
const data = log.data || log.raw_data || '0x';
|
|
tbl += '<tr id="txLogRow' + idx + '"><td>' + explorerAddressLink(addr, escapeHtml(shortenHash(addr)), 'color: inherit; text-decoration: none;') + '</td><td style="word-break: break-all; font-size: 0.75rem;">' + escapeHtml(String(topicsStr).substring(0, 80)) + (String(topicsStr).length > 80 ? '...' : '') + '</td><td style="word-break: break-all; font-size: 0.75rem;">' + escapeHtml(String(data).substring(0, 66)) + (String(data).length > 66 ? '...' : '') + '</td><td id="txLogDecoded' + idx + '" style="font-size: 0.8rem;">—</td></tr>';
|
|
});
|
|
if (filteredLogs.length === 0) {
|
|
tbl += '<tr><td colspan="4" style="text-align:center; padding: 1rem;">No event logs match the current filter.</td></tr>';
|
|
}
|
|
tbl += '</tbody></table>';
|
|
logsEl.innerHTML = tbl;
|
|
if (typeof ethers !== 'undefined' && ethers.utils) {
|
|
(function(logsList, txHash) {
|
|
var addrs = [];
|
|
logsList.forEach(function(l) { var a = l.address && (l.address.hash || l.address) || l.address; if (a && addrs.indexOf(a) === -1) addrs.push(a); });
|
|
var abiCache = {};
|
|
Promise.all(addrs.map(function(addr) {
|
|
if (!/^0x[a-f0-9]{40}$/i.test(addr)) return Promise.resolve();
|
|
return fetch(BLOCKSCOUT_API + '/v2/smart-contracts/' + addr).then(function(r) { return r.json(); }).catch(function() { return null; }).then(function(res) {
|
|
var abi = res && (res.abi || res.abi_json);
|
|
if (abi) abiCache[addr.toLowerCase()] = Array.isArray(abi) ? abi : (typeof abi === 'string' ? JSON.parse(abi) : abi);
|
|
});
|
|
})).then(function() {
|
|
logsList.forEach(function(log, idx) {
|
|
var addr = (log.address && (log.address.hash || log.address)) || log.address;
|
|
var topics = log.topics && Array.isArray(log.topics) ? log.topics : (log.topic0 ? [log.topic0] : []);
|
|
var data = log.data || log.raw_data || '0x';
|
|
var abi = addr ? abiCache[(addr + '').toLowerCase()] : null;
|
|
var decodedEl = document.getElementById('txLogDecoded' + idx);
|
|
if (!decodedEl || !abi) return;
|
|
try {
|
|
var iface = new ethers.utils.Interface(abi);
|
|
var parsed = iface.parseLog({ topics: topics, data: data });
|
|
if (parsed && parsed.name) {
|
|
var args = parsed.args && parsed.args.length ? parsed.args.map(function(a) { return String(a); }).join(', ') : '';
|
|
decodedEl.textContent = parsed.name + '(' + args + ')';
|
|
decodedEl.title = parsed.signature || '';
|
|
}
|
|
} catch (e) {}
|
|
});
|
|
});
|
|
})(logs, txHash);
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
} catch (error) {
|
|
container.innerHTML = '<div class="error">Failed to load transaction: ' + escapeHtml(error.message) + '</div>';
|
|
}
|
|
}
|
|
function escapeHtml(str) {
|
|
if (str == null) return '';
|
|
const s = String(str);
|
|
const div = document.createElement('div');
|
|
div.textContent = s;
|
|
return div.innerHTML;
|
|
}
|
|
function exportTransactionCSV(txHash) {
|
|
fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/transactions/' + txHash).then(function(r) {
|
|
var t = normalizeTransaction(r);
|
|
if (!t) return;
|
|
var rows = [['Field', 'Value'], ['hash', t.hash], ['from', t.from], ['to', t.to || ''], ['value', t.value || '0'], ['block_number', t.block_number || ''], ['status', t.status], ['gas_used', t.gas_used || ''], ['gas_limit', t.gas_limit || '']];
|
|
var csv = rows.map(function(row) { return row.map(function(cell) { return '"' + String(cell).replace(/"/g, '""') + '"'; }).join(','); }).join('\n');
|
|
var blob = new Blob([csv], { type: 'text/csv' });
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement('a'); a.href = url; a.download = 'transaction-' + txHash.substring(0, 10) + '.csv'; a.click();
|
|
URL.revokeObjectURL(url);
|
|
}).catch(function(e) { showToast('Export failed: ' + (e.message || 'Unknown'), 'error'); });
|
|
}
|
|
function exportBlocksCSV() {
|
|
Promise.resolve(CHAIN_ID === 138 ? fetchChain138BlocksPage(1, 50) : fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/blocks?page=1&page_size=50').then(function(r) {
|
|
return (r.items || r || []).map(normalizeBlock).filter(function(block) { return block !== null; });
|
|
})).then(function(items) {
|
|
var rows = [['Block', 'Hash', 'Transactions', 'Timestamp']];
|
|
items.forEach(function(b) {
|
|
var bn = b.height || b.number || b.block_number;
|
|
var h = b.hash || b.block_hash || '';
|
|
var tc = b.transaction_count || b.transactions_count || 0;
|
|
var ts = b.timestamp || '';
|
|
rows.push([String(bn), h, String(tc), String(ts)]);
|
|
});
|
|
var csv = rows.map(function(row) { return row.map(function(cell) { return '"' + String(cell).replace(/"/g, '""') + '"'; }).join(','); }).join('\n');
|
|
var blob = new Blob([csv], { type: 'text/csv' });
|
|
var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'blocks.csv'; a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
}).catch(function(e) { showToast('Export failed: ' + (e.message || 'Unknown'), 'error'); });
|
|
}
|
|
function exportTransactionsListCSV() {
|
|
Promise.resolve(CHAIN_ID === 138 ? fetchChain138TransactionsPage(1, 50) : fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/transactions?page=1&page_size=50').then(function(r) {
|
|
return (r.items || r || []).map(normalizeTransaction).filter(function(tx) { return tx !== null; });
|
|
})).then(function(items) {
|
|
var rows = [['Hash', 'From', 'To', 'Value', 'Block']];
|
|
items.forEach(function(tx) {
|
|
var t = tx && tx.hash ? tx : normalizeTransaction(tx);
|
|
if (t) rows.push([t.hash || '', t.from || '', t.to || '', t.value || '0', String(t.block_number || '')]);
|
|
});
|
|
var csv = rows.map(function(row) { return row.map(function(cell) { return '"' + String(cell).replace(/"/g, '""') + '"'; }).join(','); }).join('\n');
|
|
var blob = new Blob([csv], { type: 'text/csv' });
|
|
var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'transactions.csv'; a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
}).catch(function(e) { showToast('Export failed: ' + (e.message || 'Unknown'), 'error'); });
|
|
}
|
|
function exportAddressTransactionsCSV(addr) {
|
|
if (!addr || !/^0x[a-fA-F0-9]{40}$/i.test(addr)) { showToast('Invalid address', 'error'); return; }
|
|
fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/transactions?address=' + encodeURIComponent(addr) + '&page=1&page_size=100').then(function(r) {
|
|
var items = r.items || r || [];
|
|
var rows = [['Hash', 'From', 'To', 'Value', 'Block', 'Status']];
|
|
items.forEach(function(tx) {
|
|
var t = normalizeTransaction(tx);
|
|
if (t) rows.push([t.hash || '', t.from || '', t.to || '', t.value || '0', String(t.block_number || ''), t.status === 1 ? 'Success' : 'Failed']);
|
|
});
|
|
var csv = rows.map(function(row) { return row.map(function(cell) { return '"' + String(cell).replace(/"/g, '""') + '"'; }).join(','); }).join('\n');
|
|
var blob = new Blob([csv], { type: 'text/csv' });
|
|
var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'address-' + addr.substring(0, 10) + '-transactions.csv'; a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
showToast('CSV downloaded', 'success');
|
|
}).catch(function(e) { showToast('Export failed: ' + (e.message || 'Unknown'), 'error'); });
|
|
}
|
|
window.exportAddressTransactionsCSV = exportAddressTransactionsCSV;
|
|
function exportAddressTokenBalancesCSV(addr) {
|
|
if (!addr || !/^0x[a-fA-F0-9]{40}$/i.test(addr)) { showToast('Invalid address', 'error'); return; }
|
|
fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/addresses/' + addr + '/token-balances').catch(function() { return { items: [] }; }).then(function(r) {
|
|
var items = Array.isArray(r) ? r : (r.items || r || []);
|
|
var rows = [['Token', 'Contract', 'Balance', 'Type']];
|
|
(items || []).forEach(function(b) {
|
|
var token = b.token || b;
|
|
var contract = token.address?.hash || token.address || b.token_contract_address_hash || 'N/A';
|
|
var symbol = token.symbol || token.name || '-';
|
|
var balance = b.value || b.balance || '0';
|
|
var decimals = token.decimals != null ? token.decimals : 18;
|
|
var divisor = Math.pow(10, parseInt(decimals, 10));
|
|
var displayBalance = (Number(balance) / divisor).toLocaleString(undefined, { maximumFractionDigits: 6 });
|
|
var type = token.type || b.token_type || 'ERC-20';
|
|
rows.push([symbol, contract, displayBalance, type]);
|
|
});
|
|
var csv = rows.map(function(row) { return row.map(function(cell) { return '"' + String(cell).replace(/"/g, '""') + '"'; }).join(','); }).join('\n');
|
|
var blob = new Blob([csv], { type: 'text/csv' });
|
|
var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'address-' + addr.substring(0, 10) + '-token-balances.csv'; a.click();
|
|
URL.revokeObjectURL(a.href);
|
|
showToast('CSV downloaded', 'success');
|
|
}).catch(function(e) { showToast('Export failed: ' + (e.message || 'Unknown'), 'error'); });
|
|
}
|
|
window.exportAddressTokenBalancesCSV = exportAddressTokenBalancesCSV;
|
|
window._showTransactionDetail = renderTransactionDetail;
|
|
// Keep wrapper (do not overwrite) so all calls go through setTimeout and avoid stack overflow
|
|
|
|
async function renderAddressDetail(address) {
|
|
const addr = safeAddress(address);
|
|
if (!addr) { showToast('Invalid address', 'error'); return; }
|
|
address = addr;
|
|
currentDetailKey = 'address:' + address.toLowerCase();
|
|
showView('addressDetail');
|
|
updatePath('/address/' + address);
|
|
const container = document.getElementById('addressDetail');
|
|
updateBreadcrumb('address', address);
|
|
container.innerHTML = createSkeletonLoader('detail');
|
|
|
|
try {
|
|
// Validate address format
|
|
if (!/^0x[a-fA-F0-9]{40}$/.test(address)) {
|
|
container.innerHTML = '<div class="error">Invalid address format</div>';
|
|
return;
|
|
}
|
|
|
|
let a;
|
|
|
|
// For ChainID 138, use Blockscout API directly
|
|
if (CHAIN_ID === 138) {
|
|
try {
|
|
const response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/addresses/${address}`);
|
|
var raw = response && (response.data !== undefined ? response.data : response.address !== undefined ? response.address : response.items && response.items[0] !== undefined ? response.items[0] : response);
|
|
a = normalizeAddress(raw);
|
|
if (!a || !a.hash) {
|
|
throw new Error('Address not found');
|
|
}
|
|
} catch (error) {
|
|
var retryAddress = String(address || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
container.innerHTML = '<div class="error">Failed to load address: ' + escapeHtml(error.message || 'Unknown error') + '. <button onclick="showAddressDetail(\'' + retryAddress + '\')" class="btn btn-primary" style="margin-top: 1rem;">Retry</button></div>';
|
|
return;
|
|
}
|
|
} else {
|
|
const addr = await fetchAPIWithRetry(`${API_BASE}/v1/addresses/138/${address}`);
|
|
if (addr.data) {
|
|
a = addr.data;
|
|
} else {
|
|
throw new Error('Address not found');
|
|
}
|
|
}
|
|
|
|
if (a) {
|
|
const balanceEth = formatEther(a.balance || '0');
|
|
const isContract = !!a.is_contract;
|
|
const verifiedBadge = a.is_verified ? '<span class="badge badge-success" style="margin-left: 0.5rem;">Verified</span>' : '';
|
|
const encodedAddress = encodeURIComponent(address);
|
|
const escapedAddress = escapeHtml(address);
|
|
const addressForJs = address.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
const contractLink = isContract ? `<a href="${EXPLORER_ORIGIN}/address/${encodedAddress}/contract" target="_blank" rel="noopener noreferrer" style="color: var(--primary); font-size: 0.875rem;">View contract on Blockscout</a>` : '';
|
|
const savedLabel = getAddressLabel(address);
|
|
const inWatchlist = isInWatchlist(address);
|
|
|
|
container.innerHTML = `
|
|
<div class="info-row">
|
|
<div class="info-label">Address</div>
|
|
<div class="info-value hash">${escapedAddress} <button type="button" class="btn-copy" onclick="copyToClipboard('${addressForJs}', 'Copied');" aria-label="Copy address"><i class="fas fa-copy"></i></button></div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Label</div>
|
|
<div class="info-value"><input type="text" id="addressLabelInput" value="${escapeHtml(savedLabel)}" placeholder="Optional label" style="padding: 0.35rem; border-radius: 6px; width: 200px; max-width: 100%;"> <button type="button" class="btn btn-primary" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;" onclick="var v=document.getElementById(\'addressLabelInput\').value; setAddressLabel(\'${addressForJs}\', v); showToast(\'Label saved\', \'success\');">Save</button></div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Watchlist</div>
|
|
<div class="info-value"><button type="button" id="addressWatchlistBtn" class="btn btn-primary" style="padding: 0.35rem 0.75rem;" onclick="(function(addr){ if(isInWatchlist(addr)){ removeFromWatchlist(addr); showToast(\'Removed from watchlist\', \'success\'); } else { addToWatchlist(addr); showToast(\'Added to watchlist\', \'success\'); } var b=document.getElementById(\'addressWatchlistBtn\'); if(b) b.innerHTML=isInWatchlist(addr)?\'<i class=\'fas fa-star\'></i> Remove from watchlist\':\'<i class=\'fas fa-star-o\'></i> Add to watchlist\'; })(\'${addressForJs}\')"><i class="fas fa-star${inWatchlist ? '' : '-o'}"></i> ${inWatchlist ? 'Remove from watchlist' : 'Add to watchlist'}</button></div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Token approvals</div>
|
|
<div class="info-value"><a href="https://revoke.cash/address/${encodedAddress}${CHAIN_ID === 138 ? '?chainId=138' : ''}" target="_blank" rel="noopener noreferrer" style="color: var(--primary);">Check token approvals</a></div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Balance</div>
|
|
<div class="info-value">${balanceEth} ETH</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Transaction Count</div>
|
|
<div class="info-value">${a.transaction_count || 0}</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Token Count</div>
|
|
<div class="info-value">${a.token_count || 0}</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Type</div>
|
|
<div class="info-value">${a.is_contract ? '<span class="badge badge-success">Contract</span>' + verifiedBadge + (contractLink ? '<br/>' + contractLink : '') : '<span class="badge badge-primary">EOA</span>'}</div>
|
|
</div>
|
|
${a.creation_tx_hash ? `<div class="info-row"><div class="info-label">Contract created in</div><div class="info-value">${explorerTransactionLink(a.creation_tx_hash, escapeHtml(shortenHash(a.creation_tx_hash)), 'color: inherit; text-decoration: none;')} <button type="button" class="btn-copy" onclick="event.stopPropagation(); copyToClipboard('${escapeHtml(a.creation_tx_hash).replace(/'/g, "\\'")}', 'Copied');" aria-label="Copy"><i class="fas fa-copy"></i></button></div></div>` : ''}
|
|
${a.first_seen_at ? `<div class="info-row"><div class="info-label">First seen</div><div class="info-value">${escapeHtml(typeof a.first_seen_at === 'string' ? a.first_seen_at : new Date(a.first_seen_at).toISOString())}</div></div>` : ''}
|
|
${a.last_seen_at ? `<div class="info-row"><div class="info-label">Last seen</div><div class="info-value">${escapeHtml(typeof a.last_seen_at === 'string' ? a.last_seen_at : new Date(a.last_seen_at).toISOString())}</div></div>` : ''}
|
|
<div class="tabs" style="margin-top: 1.5rem;">
|
|
<button class="tab active" onclick="switchAddressTab('transactions', '${address}')" id="addrTabTxs" aria-selected="true">Transactions</button>
|
|
<button class="tab" onclick="switchAddressTab('tokens', '${address}')" id="addrTabTokens">Token Balances</button>
|
|
<button class="tab" onclick="switchAddressTab('internal', '${address}')" id="addrTabInternal">Internal Txns</button>
|
|
<button class="tab" onclick="switchAddressTab('nfts', '${address}')" id="addrTabNfts">NFTs</button>
|
|
${isContract ? '<button class="tab" onclick="switchAddressTab(\'contract\', \'' + address + '\')" id="addrTabContract">Contract (ABI / Bytecode)</button>' : ''}
|
|
</div>
|
|
<div id="addressTabTransactions" class="address-tab-content card" style="margin-top: 1rem;">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
|
|
<h3 style="margin: 0;">Recent Transactions</h3>
|
|
<button type="button" class="btn btn-primary" onclick="exportAddressTransactionsCSV('${address}')" style="padding: 0.5rem 1rem;"><i class="fas fa-file-csv"></i> Export CSV</button>
|
|
</div>
|
|
<div id="addressTransactions" class="loading">Loading transactions...</div>
|
|
</div>
|
|
<div id="addressTabTokens" class="address-tab-content card" style="margin-top: 1rem; display: none;">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 0.5rem;">
|
|
<h3 style="margin: 0;">Token Balances</h3>
|
|
<button type="button" class="btn btn-primary" onclick="exportAddressTokenBalancesCSV('${address}')" style="padding: 0.5rem 1rem;"><i class="fas fa-file-csv"></i> Export CSV</button>
|
|
</div>
|
|
<div id="addressTokenBalances" class="loading">Loading...</div>
|
|
</div>
|
|
<div id="addressTabInternal" class="address-tab-content card" style="margin-top: 1rem; display: none;">
|
|
<h3>Internal Transactions</h3>
|
|
<div id="addressInternalTxns" class="loading">Loading...</div>
|
|
</div>
|
|
<div id="addressTabNfts" class="address-tab-content card" style="margin-top: 1rem; display: none;">
|
|
<h3>NFT Inventory</h3>
|
|
<div id="addressNftInventory" class="loading">Loading...</div>
|
|
</div>
|
|
${isContract ? '<div id="addressTabContract" class="address-tab-content card" style="margin-top: 1rem; display: none;"><h3>Contract ABI & Bytecode</h3><div id="addressContractInfo" class="loading">Loading...</div></div>' : ''}
|
|
`;
|
|
|
|
function switchAddressTab(tabName, addr) {
|
|
document.querySelectorAll('.address-tab-content').forEach(function(el) { el.style.display = 'none'; });
|
|
document.querySelectorAll('.tabs .tab').forEach(function(t) { t.classList.remove('active'); });
|
|
if (tabName === 'transactions') {
|
|
document.getElementById('addressTabTransactions').style.display = 'block';
|
|
document.getElementById('addrTabTxs').classList.add('active');
|
|
} else if (tabName === 'tokens') {
|
|
document.getElementById('addressTabTokens').style.display = 'block';
|
|
document.getElementById('addrTabTokens').classList.add('active');
|
|
loadAddressTokenBalances(addr);
|
|
} else if (tabName === 'internal') {
|
|
document.getElementById('addressTabInternal').style.display = 'block';
|
|
document.getElementById('addrTabInternal').classList.add('active');
|
|
loadAddressInternalTxns(addr);
|
|
} else if (tabName === 'nfts') {
|
|
document.getElementById('addressTabNfts').style.display = 'block';
|
|
document.getElementById('addrTabNfts').classList.add('active');
|
|
loadAddressNftInventory(addr);
|
|
} else if (tabName === 'contract') {
|
|
var contractPanel = document.getElementById('addressTabContract');
|
|
var contractTab = document.getElementById('addrTabContract');
|
|
if (contractPanel && contractTab) {
|
|
contractPanel.style.display = 'block';
|
|
contractTab.classList.add('active');
|
|
loadAddressContractInfo(addr);
|
|
}
|
|
}
|
|
}
|
|
window.switchAddressTab = switchAddressTab;
|
|
|
|
async function loadAddressTokenBalances(addr) {
|
|
const el = document.getElementById('addressTokenBalances');
|
|
if (!el || el.dataset.loaded === '1') return;
|
|
try {
|
|
const r = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/addresses/' + addr + '/token-balances').catch(function() { return { items: [] }; });
|
|
const r2 = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/addresses/' + addr + '/token_balances').catch(function() { return { items: [] }; });
|
|
const items = (r.items || r).length ? (r.items || r) : (r2.items || r2);
|
|
el.dataset.loaded = '1';
|
|
const filter = getExplorerPageFilter('addressTokenBalances');
|
|
const reloadJs = 'showAddressDetail(\'' + addr.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + '\')';
|
|
const filterBar = renderPageFilterBar('addressTokenBalances', 'Filter by token name, contract, balance, or type...', 'Filters the token balances below.', reloadJs);
|
|
if (!items || items.length === 0) {
|
|
el.innerHTML = filterBar + '<p style="color: var(--text-light);">No token balances</p>';
|
|
return;
|
|
}
|
|
const filteredItems = filter ? items.filter(function(b) {
|
|
const token = b.token || b;
|
|
const contract = token.address?.hash || token.address || b.token_contract_address_hash || 'N/A';
|
|
const symbol = token.symbol || token.name || '-';
|
|
const balance = b.value || b.balance || '0';
|
|
const decimals = token.decimals != null ? token.decimals : 18;
|
|
const divisor = Math.pow(10, parseInt(decimals, 10));
|
|
const displayBalance = (Number(balance) / divisor).toLocaleString(undefined, { maximumFractionDigits: 6 });
|
|
const type = token.type || b.token_type || 'ERC-20';
|
|
return matchesExplorerFilter([symbol, contract, displayBalance, type].join(' '), filter);
|
|
}) : items;
|
|
let tbl = filterBar + '<table class="table"><thead><tr><th>Token</th><th>Contract</th><th>Balance</th><th>Type</th></tr></thead><tbody>';
|
|
filteredItems.forEach(function(b) {
|
|
const token = b.token || b;
|
|
const contract = token.address?.hash || token.address || b.token_contract_address_hash || 'N/A';
|
|
const symbol = token.symbol || token.name || '-';
|
|
const balance = b.value || b.balance || '0';
|
|
const decimals = token.decimals != null ? token.decimals : 18;
|
|
const divisor = Math.pow(10, parseInt(decimals, 10));
|
|
const displayBalance = (Number(balance) / divisor).toLocaleString(undefined, { maximumFractionDigits: 6 });
|
|
const type = token.type || b.token_type || 'ERC-20';
|
|
tbl += '<tr><td><a href="/token/' + encodeURIComponent(contract) + '">' + escapeHtml(symbol) + '</a></td><td>' + explorerAddressLink(contract, escapeHtml(shortenHash(contract)), 'color: inherit; text-decoration: none;') + '</td><td>' + escapeHtml(displayBalance) + '</td><td>' + escapeHtml(type) + '</td></tr>';
|
|
});
|
|
if (filteredItems.length === 0) {
|
|
tbl += '<tr><td colspan="4" style="text-align:center; padding: 1rem;">No token balances match the current filter.</td></tr>';
|
|
}
|
|
tbl += '</tbody></table>';
|
|
el.innerHTML = tbl;
|
|
} catch (e) {
|
|
el.innerHTML = '<p class="error">Failed to load token balances</p>';
|
|
}
|
|
}
|
|
|
|
async function loadAddressNftInventory(addr) {
|
|
const el = document.getElementById('addressNftInventory');
|
|
if (!el || el.dataset.loaded === '1') return;
|
|
try {
|
|
const r = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/addresses/' + addr + '/token-balances').catch(function() { return { items: [] }; });
|
|
const r2 = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/addresses/' + addr + '/nft-inventory').catch(function() { return { items: [] }; });
|
|
const r3 = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/addresses/' + addr + '/nft_tokens').catch(function() { return { items: [] }; });
|
|
var items = (r2.items || r2).length ? (r2.items || r2) : (r3.items || r3);
|
|
if (!items || items.length === 0) {
|
|
var allBalances = (r.items || r) || [];
|
|
items = Array.isArray(allBalances) ? allBalances.filter(function(b) {
|
|
var t = (b.token || b).type || (b.token_type || '');
|
|
return t === 'ERC-721' || t === 'ERC-1155' || String(t).toLowerCase().indexOf('nft') !== -1;
|
|
}) : [];
|
|
}
|
|
el.dataset.loaded = '1';
|
|
const filter = getExplorerPageFilter('addressNftInventory');
|
|
const reloadJs = 'showAddressDetail(\'' + addr.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + '\')';
|
|
const filterBar = renderPageFilterBar('addressNftInventory', 'Filter by contract, token ID, name, symbol, or balance...', 'Filters the NFT inventory below.', reloadJs);
|
|
if (!items || items.length === 0) {
|
|
el.innerHTML = filterBar + '<p style="color: var(--text-light);">No NFT tokens</p>';
|
|
return;
|
|
}
|
|
const filteredItems = filter ? items.filter(function(b) {
|
|
var token = b.token || b;
|
|
var contract = token.address?.hash || token.address || b.token_contract_address_hash || b.contract_address_hash || 'N/A';
|
|
var tokenId = b.token_id != null ? b.token_id : (b.tokenId != null ? b.tokenId : (b.id != null ? b.id : '-'));
|
|
var name = token.name || token.symbol || '-';
|
|
var balance = b.value != null ? b.value : (b.balance != null ? b.balance : '1');
|
|
return matchesExplorerFilter([contract, tokenId, name, balance].join(' '), filter);
|
|
}) : items;
|
|
var tbl = filterBar + '<table class="table"><thead><tr><th>Contract</th><th>Token ID</th><th>Name / Symbol</th><th>Balance</th></tr></thead><tbody>';
|
|
filteredItems.forEach(function(b) {
|
|
var token = b.token || b;
|
|
var contract = token.address?.hash || token.address || b.token_contract_address_hash || b.contract_address_hash || 'N/A';
|
|
var tokenId = b.token_id != null ? b.token_id : (b.tokenId != null ? b.tokenId : (b.id != null ? b.id : '-'));
|
|
var name = token.name || token.symbol || '-';
|
|
var balance = b.value != null ? b.value : (b.balance != null ? b.balance : '1');
|
|
tbl += '<tr><td>' + explorerAddressLink(contract, escapeHtml(shortenHash(contract)), 'color: inherit; text-decoration: none;') + '</td>';
|
|
tbl += '<td>' + (tokenId !== '-' ? '<a href="/nft/' + encodeURIComponent(contract) + '/' + encodeURIComponent(String(tokenId)) + '" onclick="event.preventDefault(); showNftDetail(\'' + escapeHtml(contract) + '\', \'' + escapeHtml(String(tokenId)) + '\'); updatePath(\'/nft/' + encodeURIComponent(contract) + '/' + encodeURIComponent(String(tokenId)) + '\');">' + escapeHtml(String(tokenId)) + '</a>' : '-') + '</td>';
|
|
tbl += '<td>' + escapeHtml(name) + '</td><td>' + escapeHtml(String(balance)) + '</td></tr>';
|
|
});
|
|
if (filteredItems.length === 0) {
|
|
tbl += '<tr><td colspan="4" style="text-align:center; padding: 1rem;">No NFT inventory matches the current filter.</td></tr>';
|
|
}
|
|
tbl += '</tbody></table>';
|
|
el.innerHTML = tbl;
|
|
} catch (e) {
|
|
el.innerHTML = '<p class="error">Failed to load NFT inventory</p>';
|
|
}
|
|
}
|
|
|
|
async function loadAddressInternalTxns(addr) {
|
|
const el = document.getElementById('addressInternalTxns');
|
|
if (!el || el.dataset.loaded === '1') return;
|
|
try {
|
|
const r = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/addresses/' + addr + '/internal-transactions').catch(function() { return { items: [] }; });
|
|
const r2 = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/addresses/' + addr + '/internal_transactions').catch(function() { return { items: [] }; });
|
|
const items = (r.items || r).length ? (r.items || r) : (r2.items || r2);
|
|
el.dataset.loaded = '1';
|
|
const filter = getExplorerPageFilter('addressInternalTxns');
|
|
const reloadJs = 'showAddressDetail(\'' + addr.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + '\')';
|
|
const filterBar = renderPageFilterBar('addressInternalTxns', 'Filter by block, from, to, tx hash, or value...', 'Filters the internal transactions below.', reloadJs);
|
|
if (!items || items.length === 0) {
|
|
el.innerHTML = filterBar + '<p style="color: var(--text-light);">No internal transactions</p>';
|
|
return;
|
|
}
|
|
const slicedItems = items.slice(0, 25);
|
|
const filteredItems = filter ? slicedItems.filter(function(it) {
|
|
const from = it.from?.hash || it.from || 'N/A';
|
|
const to = it.to?.hash || it.to || 'N/A';
|
|
const val = it.value ? formatEther(it.value) : '0';
|
|
const block = it.block_number || it.block || '-';
|
|
const txHash = it.transaction_hash || it.tx_hash || '-';
|
|
return matchesExplorerFilter([block, from, to, val, txHash].join(' '), filter);
|
|
}) : slicedItems;
|
|
let tbl = filterBar + '<table class="table"><thead><tr><th>Block</th><th>From</th><th>To</th><th>Value</th><th>Tx Hash</th></tr></thead><tbody>';
|
|
filteredItems.forEach(function(it) {
|
|
const from = it.from?.hash || it.from || 'N/A';
|
|
const to = it.to?.hash || it.to || 'N/A';
|
|
const val = it.value ? formatEther(it.value) : '0';
|
|
const block = it.block_number || it.block || '-';
|
|
const txHash = it.transaction_hash || it.tx_hash || '-';
|
|
tbl += '<tr><td>' + explorerBlockLink(block, escapeHtml(block), 'color: inherit; text-decoration: none;') + '</td><td>' + explorerAddressLink(from, escapeHtml(shortenHash(from)), 'color: inherit; text-decoration: none;') + '</td><td>' + explorerAddressLink(to, escapeHtml(shortenHash(to)), 'color: inherit; text-decoration: none;') + '</td><td>' + escapeHtml(val) + ' ETH</td><td>' + (txHash !== '-' ? explorerTransactionLink(txHash, escapeHtml(shortenHash(txHash)), 'color: inherit; text-decoration: none;') : '-') + '</td></tr>';
|
|
});
|
|
if (filteredItems.length === 0) {
|
|
tbl += '<tr><td colspan="5" style="text-align:center; padding: 1rem;">No internal transactions match the current filter.</td></tr>';
|
|
}
|
|
tbl += '</tbody></table>';
|
|
el.innerHTML = tbl;
|
|
} catch (e) {
|
|
el.innerHTML = '<p class="error">Failed to load internal transactions</p>';
|
|
}
|
|
}
|
|
|
|
async function loadAddressContractInfo(addr) {
|
|
const el = document.getElementById('addressContractInfo');
|
|
if (!el || el.dataset.loaded === '1') return;
|
|
try {
|
|
const urls = [
|
|
BLOCKSCOUT_API + '/v2/smart-contracts/' + addr,
|
|
BLOCKSCOUT_API + '/v2/contracts/' + addr
|
|
];
|
|
let data = null;
|
|
for (var i = 0; i < urls.length; i++) {
|
|
try {
|
|
const r = await fetchAPIWithRetry(urls[i]);
|
|
if (r && (r.abi || r.bytecode || r.deployed_bytecode)) { data = r; break; }
|
|
} catch (e) {}
|
|
}
|
|
el.dataset.loaded = '1';
|
|
if (!data) {
|
|
el.innerHTML = '<p style="color: var(--text-light);">Contract source not indexed. <a href="' + EXPLORER_ORIGIN + '/address/' + encodeURIComponent(addr) + '/contract" target="_blank" rel="noopener noreferrer">Verify on Blockscout</a></p>';
|
|
return;
|
|
}
|
|
const abi = data.abi || data.abi_interface || [];
|
|
const abiStr = typeof abi === 'string' ? abi : JSON.stringify(abi, null, 2);
|
|
const bytecode = data.bytecode || data.deployed_bytecode || data.creation_bytecode || '-';
|
|
let html = '<p><strong>Verification:</strong> ' + (data.is_verified ? '<span class="badge badge-success">Verified</span>' : '<span class="badge badge-warning">Unverified</span>') + '</p>';
|
|
html += '<div style="margin-top: 1rem;"><h4>ABI <button type="button" class="btn btn-primary" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;" onclick="navigator.clipboard.writeText(document.getElementById(\'contractAbiText\').textContent); showToast(\'Copied\', \'success\');">Copy</button> <a href="javascript:void(0)" onclick="var blob=new Blob([document.getElementById(\'contractAbiText\').textContent],{type:\'application/json\'}); var a=document.createElement(\'a\'); a.href=URL.createObjectURL(blob); a.download=\'abi-' + addr.substring(0,10) + '.json\'; a.click(); URL.revokeObjectURL(a.href); return false;">Download</a></h4>';
|
|
html += '<pre id="contractAbiText" style="background: var(--light); padding: 1rem; border-radius: 8px; overflow: auto; max-height: 300px; font-size: 0.75rem;">' + escapeHtml(abiStr) + '</pre></div>';
|
|
html += '<div style="margin-top: 1rem;"><h4>Bytecode <button type="button" class="btn btn-primary" style="padding: 0.25rem 0.5rem; font-size: 0.75rem;" onclick="navigator.clipboard.writeText(document.getElementById(\'contractBytecodeText\').textContent); showToast(\'Copied\', \'success\');">Copy</button></h4>';
|
|
html += '<pre id="contractBytecodeText" style="background: var(--light); padding: 1rem; border-radius: 8px; overflow: auto; max-height: 150px; font-size: 0.7rem; word-break: break-all;">' + escapeHtml(String(bytecode).substring(0, 500)) + (String(bytecode).length > 500 ? '...' : '') + '</pre></div>';
|
|
var viewFns = (Array.isArray(abi) ? abi : []).filter(function(x) { return x.type === 'function' && (x.stateMutability === 'view' || x.stateMutability === 'pure' || (x.constant === true)); });
|
|
if (viewFns.length > 0) {
|
|
html += '<div style="margin-top: 1.5rem;"><h4><i class="fas fa-readme"></i> Read contract</h4><p style="color: var(--text-light); font-size: 0.875rem;">Call view/pure functions (requires ethers.js).</p>';
|
|
html += '<div style="margin-top: 0.5rem;"><label for="readContractSelect">Function:</label><select id="readContractSelect" style="margin-left: 0.5rem; padding: 0.35rem; border-radius: 6px; min-width: 200px;">';
|
|
viewFns.forEach(function(fn) { html += '<option value="' + escapeHtml(fn.name) + '">' + escapeHtml(fn.name) + '(' + (fn.inputs || []).map(function(i) { return i.type; }).join(',') + ')</option>'; });
|
|
html += '</select></div><div id="readContractInputs" style="margin-top: 0.5rem;"></div>';
|
|
html += '<button type="button" class="btn btn-primary" style="margin-top: 0.5rem;" id="readContractBtn">Query</button><pre id="readContractResult" style="background: var(--light); padding: 1rem; border-radius: 8px; margin-top: 0.5rem; min-height: 2rem; font-size: 0.8rem; display: none;"></pre></div>';
|
|
}
|
|
var writeFns = (Array.isArray(abi) ? abi : []).filter(function(x) { return x.type === 'function' && x.stateMutability !== 'view' && x.stateMutability !== 'pure' && !x.constant; });
|
|
if (writeFns.length > 0) {
|
|
html += '<div style="margin-top: 1.5rem;"><h4><i class="fas fa-pen"></i> Write contract</h4><p style="color: var(--text-light); font-size: 0.875rem;">Connect wallet to send transactions.</p>';
|
|
html += '<div style="margin-top: 0.5rem;"><label>Function:</label><select id="writeContractSelect" style="margin-left: 0.5rem; padding: 0.35rem; border-radius: 6px; min-width: 200px;">';
|
|
writeFns.forEach(function(fn) { var pay = (fn.stateMutability === 'payable'); html += '<option value="' + escapeHtml(fn.name) + '" data-payable="' + pay + '">' + escapeHtml(fn.name) + '(' + (fn.inputs || []).map(function(i) { return i.type; }).join(',') + ')' + (pay ? ' payable' : '') + '</option>'; });
|
|
html += '</select></div><div id="writeContractInputs" style="margin-top: 0.5rem;"></div>';
|
|
html += '<div id="writeContractValueRow" style="margin-top: 0.5rem; display: none;"><label>Value (ETH):</label><input type="text" id="writeContractValue" placeholder="0" style="margin-left: 0.5rem; padding: 0.35rem; border-radius: 6px; width: 120px;"></div>';
|
|
html += '<button type="button" class="btn btn-primary" style="margin-top: 0.5rem;" id="writeContractBtn">Write</button><pre id="writeContractResult" style="background: var(--light); padding: 1rem; border-radius: 8px; margin-top: 0.5rem; min-height: 2rem; font-size: 0.8rem; display: none;"></pre></div>';
|
|
}
|
|
html += '<p style="margin-top: 0.5rem;"><a href="' + EXPLORER_ORIGIN + '/address/' + encodeURIComponent(addr) + '/contract" target="_blank" rel="noopener noreferrer" style="color: var(--primary);">Read / Write contract on Blockscout</a></p>';
|
|
el.innerHTML = html;
|
|
if (viewFns.length > 0) {
|
|
(function setupReadContract(contractAddr, abiJson, viewFunctions) {
|
|
var selectEl = document.getElementById('readContractSelect');
|
|
var inputsEl = document.getElementById('readContractInputs');
|
|
var resultEl = document.getElementById('readContractResult');
|
|
var btnEl = document.getElementById('readContractBtn');
|
|
function renderInputs() {
|
|
var name = selectEl && selectEl.value;
|
|
var fn = viewFunctions.find(function(f) { return f.name === name; });
|
|
if (!inputsEl || !fn) return;
|
|
var inputs = fn.inputs || [];
|
|
if (inputs.length === 0) { inputsEl.innerHTML = ''; return; }
|
|
var h = '<div style="display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;">';
|
|
inputs.forEach(function(inp, i) {
|
|
h += '<label>' + escapeHtml(inp.name || 'arg' + i) + ' (' + escapeHtml(inp.type) + '):</label><input type="text" id="readArg' + i + '" placeholder="' + escapeHtml(inp.type) + '" style="padding: 0.35rem; border-radius: 6px; min-width: 120px;">';
|
|
});
|
|
h += '</div>';
|
|
inputsEl.innerHTML = h;
|
|
}
|
|
if (selectEl) selectEl.addEventListener('change', renderInputs);
|
|
renderInputs();
|
|
if (btnEl) btnEl.addEventListener('click', function() {
|
|
if (typeof ethers === 'undefined') { showToast('Ethers.js not loaded. Refresh the page.', 'error'); return; }
|
|
var name = selectEl && selectEl.value;
|
|
var fn = viewFunctions.find(function(f) { return f.name === name; });
|
|
if (!fn || !resultEl) return;
|
|
var inputs = fn.inputs || [];
|
|
var args = [];
|
|
for (var i = 0; i < inputs.length; i++) {
|
|
var inp = inputs[i];
|
|
var val = document.getElementById('readArg' + i) && document.getElementById('readArg' + i).value;
|
|
if (val === undefined || val === '') val = '';
|
|
var t = (inp.type || '').toLowerCase();
|
|
if (t.indexOf('uint') === 0 || t === 'int256') args.push(val ? (val.trim() ? BigInt(val) : 0) : 0);
|
|
else if (t === 'bool') args.push(val === 'true' || val === '1');
|
|
else if (t.indexOf('address') === 0) args.push(val && val.trim() ? val.trim() : '0x0000000000000000000000000000000000000000');
|
|
else args.push(val);
|
|
}
|
|
resultEl.style.display = 'block';
|
|
resultEl.textContent = 'Calling...';
|
|
var provider = new ethers.providers.JsonRpcProvider(RPC_URL);
|
|
var contract = new ethers.Contract(contractAddr, abiJson, provider);
|
|
contract[name].apply(contract, args).then(function(res) {
|
|
if (Array.isArray(res)) resultEl.textContent = JSON.stringify(res, null, 2);
|
|
else if (res != null && typeof res.toString === 'function') resultEl.textContent = res.toString();
|
|
else resultEl.textContent = JSON.stringify(res, null, 2);
|
|
}).catch(function(err) {
|
|
resultEl.textContent = 'Error: ' + (err.message || String(err));
|
|
});
|
|
});
|
|
})(addr, abi, viewFns);
|
|
}
|
|
if (writeFns.length > 0) {
|
|
(function setupWriteContract(contractAddr, abiJson, writeFunctions) {
|
|
var selectEl = document.getElementById('writeContractSelect');
|
|
var inputsEl = document.getElementById('writeContractInputs');
|
|
var valueRow = document.getElementById('writeContractValueRow');
|
|
var valueEl = document.getElementById('writeContractValue');
|
|
var resultEl = document.getElementById('writeContractResult');
|
|
var btnEl = document.getElementById('writeContractBtn');
|
|
function renderWriteInputs() {
|
|
var name = selectEl && selectEl.value;
|
|
var opt = selectEl && selectEl.options[selectEl.selectedIndex];
|
|
var payable = opt && opt.getAttribute('data-payable') === 'true';
|
|
if (valueRow) valueRow.style.display = payable ? 'block' : 'none';
|
|
var fn = writeFunctions.find(function(f) { return f.name === name; });
|
|
if (!inputsEl || !fn) return;
|
|
var inputs = fn.inputs || [];
|
|
var h = '<div style="display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;">';
|
|
inputs.forEach(function(inp, i) {
|
|
h += '<label>' + escapeHtml(inp.name || 'arg' + i) + ' (' + escapeHtml(inp.type) + '):</label><input type="text" id="writeArg' + i + '" placeholder="' + escapeHtml(inp.type) + '" style="padding: 0.35rem; border-radius: 6px; min-width: 120px;">';
|
|
});
|
|
h += '</div>';
|
|
inputsEl.innerHTML = h;
|
|
}
|
|
if (selectEl) selectEl.addEventListener('change', renderWriteInputs);
|
|
renderWriteInputs();
|
|
if (btnEl) btnEl.addEventListener('click', function() {
|
|
if (typeof ethers === 'undefined') { showToast('Ethers.js not loaded. Refresh the page.', 'error'); return; }
|
|
if (!window.ethereum) { showToast('Connect MetaMask to write.', 'error'); return; }
|
|
var name = selectEl && selectEl.value;
|
|
var fn = writeFunctions.find(function(f) { return f.name === name; });
|
|
if (!fn || !resultEl) return;
|
|
var inputs = fn.inputs || [];
|
|
var args = [];
|
|
for (var i = 0; i < inputs.length; i++) {
|
|
var inp = inputs[i];
|
|
var val = document.getElementById('writeArg' + i) && document.getElementById('writeArg' + i).value;
|
|
if (val === undefined || val === '') val = '';
|
|
var t = (inp.type || '').toLowerCase();
|
|
if (t.indexOf('uint') === 0 || t === 'int256') args.push(val ? (val.trim() ? BigInt(val) : 0) : 0);
|
|
else if (t === 'bool') args.push(val === 'true' || val === '1');
|
|
else if (t.indexOf('address') === 0) args.push(val && val.trim() ? val.trim() : '0x0000000000000000000000000000000000000000');
|
|
else args.push(val);
|
|
}
|
|
var valueWei = '0';
|
|
if (fn.stateMutability === 'payable' && valueEl && valueEl.value) {
|
|
try { valueWei = ethers.utils.parseEther(valueEl.value.trim() || '0').toString(); } catch (e) { showToast('Invalid ETH value', 'error'); return; }
|
|
}
|
|
resultEl.style.display = 'block';
|
|
resultEl.textContent = 'Confirm in wallet...';
|
|
var provider = new ethers.providers.Web3Provider(window.ethereum);
|
|
provider.send('eth_requestAccounts', []).then(function() {
|
|
var signer = provider.getSigner();
|
|
var contract = new ethers.Contract(contractAddr, abiJson, signer);
|
|
var overrides = valueWei !== '0' ? { value: valueWei } : {};
|
|
return contract[name].apply(contract, args.concat([overrides]));
|
|
}).then(function(tx) {
|
|
resultEl.textContent = 'Tx hash: ' + tx.hash + '\nWaiting for confirmation...';
|
|
return tx.wait();
|
|
}).then(function(receipt) {
|
|
resultEl.textContent = 'Success. Block: ' + receipt.blockNumber + ', Tx: ' + receipt.transactionHash;
|
|
showToast('Transaction confirmed', 'success');
|
|
}).catch(function(err) {
|
|
resultEl.textContent = 'Error: ' + (err.message || String(err));
|
|
showToast(err.message || 'Transaction failed', 'error');
|
|
});
|
|
});
|
|
})(addr, abi, writeFns);
|
|
}
|
|
} catch (e) {
|
|
el.innerHTML = '<p class="error">Failed to load contract info</p>';
|
|
}
|
|
}
|
|
|
|
try {
|
|
let txs;
|
|
if (CHAIN_ID === 138) {
|
|
const response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/transactions?address=${address}&page=1&page_size=10`);
|
|
const rawTxs = response.items || [];
|
|
txs = { data: rawTxs.map(normalizeTransaction).filter(tx => tx !== null) };
|
|
} else {
|
|
txs = await fetchAPIWithRetry(`${API_BASE}/v1/transactions?from_address=${address}&page_size=10`);
|
|
}
|
|
const txContainer = document.getElementById('addressTransactions');
|
|
if (txContainer) {
|
|
const filter = getExplorerPageFilter('addressTransactions');
|
|
const reloadJs = 'showAddressDetail(\'' + address.replace(/\\/g, '\\\\').replace(/'/g, "\\'") + '\')';
|
|
const filterBar = renderPageFilterBar('addressTransactions', 'Filter by hash, block, from, to, or value...', 'Filters the recent transactions below.', reloadJs);
|
|
if (txs.data && txs.data.length > 0) {
|
|
const filteredTxs = filter ? txs.data.filter(function(tx) {
|
|
return matchesExplorerFilter([tx.hash || '', tx.block_number || '', tx.from || '', tx.to || '', tx.value || '0'].join(' '), filter);
|
|
}) : txs.data;
|
|
let txHtml = filterBar + '<table class="table"><thead><tr><th>Hash</th><th>Block</th><th>From</th><th>To</th><th>Value</th></tr></thead><tbody>';
|
|
filteredTxs.forEach(function(tx) {
|
|
txHtml += '<tr onclick="showTransactionDetail(\'' + escapeHtml(tx.hash) + '\')" style="cursor: pointer;"><td>' + explorerTransactionLink(tx.hash, escapeHtml(shortenHash(tx.hash)), 'color: inherit; text-decoration: none;') + '</td><td>' + explorerBlockLink(String(tx.block_number), escapeHtml(String(tx.block_number)), 'color: inherit; text-decoration: none;') + '</td><td>' + explorerAddressLink(tx.from, formatAddressWithLabel(tx.from), 'color: inherit; text-decoration: none;') + '</td><td>' + (tx.to ? explorerAddressLink(tx.to, formatAddressWithLabel(tx.to), 'color: inherit; text-decoration: none;') : 'N/A') + '</td><td>' + escapeHtml(formatEther(tx.value || '0')) + ' ETH</td></tr>';
|
|
});
|
|
if (filteredTxs.length === 0) {
|
|
txHtml += '<tr><td colspan="5" style="text-align:center; padding: 1rem;">No transactions match the current filter.</td></tr>';
|
|
}
|
|
txHtml += '</tbody></table>';
|
|
txContainer.innerHTML = txHtml;
|
|
} else {
|
|
txContainer.innerHTML = filterBar + '<p>No transactions found</p>';
|
|
}
|
|
}
|
|
} catch (e) {
|
|
const txContainer = document.getElementById('addressTransactions');
|
|
if (txContainer) txContainer.innerHTML = '<p>Failed to load transactions</p>';
|
|
}
|
|
} else {
|
|
container.innerHTML = '<div class="error">Address not found</div>';
|
|
}
|
|
} catch (error) {
|
|
container.innerHTML = '<div class="error">Failed to load address: ' + escapeHtml(error.message) + '</div>';
|
|
}
|
|
}
|
|
window._showAddressDetail = renderAddressDetail;
|
|
// Keep wrapper (do not overwrite) so all calls go through setTimeout and avoid stack overflow
|
|
|
|
async function showTokenDetail(tokenAddress) {
|
|
if (!/^0x[a-fA-F0-9]{40}$/.test(tokenAddress)) return;
|
|
currentDetailKey = 'token:' + tokenAddress.toLowerCase();
|
|
showView('tokenDetail');
|
|
updatePath('/token/' + tokenAddress);
|
|
var container = document.getElementById('tokenDetail');
|
|
updateBreadcrumb('token', tokenAddress);
|
|
container.innerHTML = createSkeletonLoader('detail');
|
|
|
|
try {
|
|
var urls = [BLOCKSCOUT_API + '/v2/tokens/' + tokenAddress, BLOCKSCOUT_API + '/v2/token/' + tokenAddress];
|
|
var data = null;
|
|
for (var i = 0; i < urls.length; i++) {
|
|
try {
|
|
var r = await fetchAPIWithRetry(urls[i]);
|
|
if (r && (r.symbol || r.name || r.total_supply != null)) { data = r; break; }
|
|
} catch (e) {}
|
|
}
|
|
if (!data) {
|
|
container.innerHTML = '<p class="error">Token not found or not indexed.</p><p><a href="/address/' + encodeURIComponent(tokenAddress) + '">View as address</a></p>';
|
|
return;
|
|
}
|
|
var knownTokenDetail = {
|
|
'0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2': { name: 'Wrapped Ether', symbol: 'WETH', decimals: 18 },
|
|
'0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f': { name: 'Wrapped Ether v10', symbol: 'WETH', decimals: 18 }
|
|
};
|
|
var known = knownTokenDetail[tokenAddress.toLowerCase()];
|
|
var name = (known && known.name) || data.name || '-';
|
|
var symbol = (known && known.symbol) || data.symbol || '-';
|
|
var decimals = (known && known.decimals != null) ? known.decimals : (data.decimals != null ? data.decimals : 18);
|
|
decimals = parseInt(decimals, 10);
|
|
if (isNaN(decimals) || decimals < 0 || decimals > 255) decimals = 18;
|
|
var supply = data.total_supply != null ? data.total_supply : (data.total_supply_raw || '0');
|
|
var supplyNum = Number(supply) / Math.pow(10, decimals);
|
|
var holders = data.holders_count != null ? data.holders_count : (data.holder_count || '-');
|
|
var transfersResp = null;
|
|
try {
|
|
transfersResp = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/tokens/' + tokenAddress + '/transfers?page=1&page_size=10').catch(function() { return { items: [] }; });
|
|
} catch (e) {}
|
|
var transfers = (transfersResp && transfersResp.items) ? transfersResp.items : [];
|
|
var addrEsc = tokenAddress.replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
var symbolEsc = String(symbol).replace(/\\/g, '\\\\').replace(/'/g, "\\'");
|
|
var html = '<div class="token-detail-actions" style="margin-bottom: 1.25rem;"><button type="button" class="btn btn-primary btn-add-token-wallet-prominent" onclick="window.addTokenToWallet && window.addTokenToWallet(\'' + addrEsc + '\', \'' + symbolEsc + '\', ' + decimals + ');" aria-label="Add token to wallet"><i class="fas fa-wallet" aria-hidden="true"></i> Add to wallet (MetaMask)</button></div>';
|
|
html += '<div class="info-row"><div class="info-label">Contract</div><div class="info-value">' + explorerAddressLink(tokenAddress, escapeHtml(tokenAddress), 'color: inherit; text-decoration: none;') + ' <button type="button" class="btn-add-token-wallet" onclick="window.addTokenToWallet && window.addTokenToWallet(\'' + addrEsc + '\', \'' + symbolEsc + '\', ' + decimals + ');" aria-label="Add to wallet" title="Add to wallet"><i class="fas fa-wallet" aria-hidden="true"></i></button></div></div>';
|
|
html += '<div class="info-row"><div class="info-label">Name</div><div class="info-value">' + escapeHtml(name) + '</div></div>';
|
|
html += '<div class="info-row"><div class="info-label">Symbol</div><div class="info-value">' + escapeHtml(symbol) + '</div></div>';
|
|
html += '<div class="info-row"><div class="info-label">Decimals</div><div class="info-value">' + decimals + '</div></div>';
|
|
html += '<div class="info-row"><div class="info-label">Total Supply</div><div class="info-value">' + supplyNum.toLocaleString(undefined, { maximumFractionDigits: 6 }) + '</div></div>';
|
|
html += '<div class="info-row"><div class="info-label">Holders</div><div class="info-value">' + (holders !== '-' ? formatNumber(holders) : '-') + '</div></div>';
|
|
const transfersFilter = getExplorerPageFilter('tokenTransfers');
|
|
const transfersFilterBar = renderPageFilterBar('tokenTransfers', 'Filter by from, to, value, or tx hash...', 'Filters the recent transfers below.', 'showTokenDetail(\'' + addrEsc + '\')');
|
|
html += '<div class="card" style="margin-top: 1rem;"><h3>Recent Transfers</h3>';
|
|
if (transfers.length === 0) {
|
|
html += transfersFilterBar + '<p style="color: var(--text-light);">No transfers</p>';
|
|
} else {
|
|
const filteredTransfers = transfersFilter ? transfers.filter(function(tr) {
|
|
var from = tr.from?.hash || tr.from || '-';
|
|
var to = tr.to?.hash || tr.to || '-';
|
|
var val = tr.total?.value != null ? tr.total.value : (tr.value || '0');
|
|
var dec = tr.token?.decimals != null ? tr.token.decimals : decimals;
|
|
var v = Number(val) / Math.pow(10, parseInt(dec, 10));
|
|
var txHash = tr.transaction_hash || tr.tx_hash || '';
|
|
return matchesExplorerFilter([from, to, v.toLocaleString(undefined, { maximumFractionDigits: 6 }), txHash].join(' '), transfersFilter);
|
|
}) : transfers;
|
|
html += transfersFilterBar + '<table class="table"><thead><tr><th>From</th><th>To</th><th>Value</th><th>Tx</th></tr></thead><tbody>';
|
|
filteredTransfers.forEach(function(tr) {
|
|
var from = tr.from?.hash || tr.from || '-';
|
|
var to = tr.to?.hash || tr.to || '-';
|
|
var val = tr.total?.value != null ? tr.total.value : (tr.value || '0');
|
|
var dec = tr.token?.decimals != null ? tr.token.decimals : decimals;
|
|
var v = Number(val) / Math.pow(10, parseInt(dec, 10));
|
|
var txHash = tr.transaction_hash || tr.tx_hash || '';
|
|
html += '<tr><td>' + explorerAddressLink(from, escapeHtml(shortenHash(from)), 'color: inherit; text-decoration: none;') + '</td><td>' + explorerAddressLink(to, escapeHtml(shortenHash(to)), 'color: inherit; text-decoration: none;') + '</td><td>' + escapeHtml(v.toLocaleString(undefined, { maximumFractionDigits: 6 })) + '</td><td>' + (txHash ? explorerTransactionLink(txHash, escapeHtml(shortenHash(txHash)), 'color: inherit; text-decoration: none;') : '-') + '</td></tr>';
|
|
});
|
|
if (filteredTransfers.length === 0) {
|
|
html += '<tr><td colspan="4" style="text-align:center; padding: 1rem;">No transfers match the current filter.</td></tr>';
|
|
}
|
|
html += '</tbody></table>';
|
|
}
|
|
html += '</div>';
|
|
container.innerHTML = html;
|
|
} catch (err) {
|
|
container.innerHTML = '<div class="error">Failed to load token: ' + escapeHtml(err.message || 'Unknown') + '</div>';
|
|
}
|
|
}
|
|
window.showTokenDetail = showTokenDetail;
|
|
|
|
async function showNftDetail(contractAddress, tokenId) {
|
|
if (!/^0x[a-fA-F0-9]{40}$/.test(contractAddress)) return;
|
|
currentDetailKey = 'nft:' + contractAddress.toLowerCase() + ':' + tokenId;
|
|
showView('nftDetail');
|
|
updatePath('/nft/' + contractAddress + '/' + tokenId);
|
|
var container = document.getElementById('nftDetail');
|
|
updateBreadcrumb('nft', contractAddress, tokenId);
|
|
container.innerHTML = createSkeletonLoader('detail');
|
|
|
|
try {
|
|
var urls = [BLOCKSCOUT_API + '/v2/tokens/' + contractAddress + '/nft/' + tokenId, BLOCKSCOUT_API + '/v2/nft/' + contractAddress + '/' + tokenId];
|
|
var data = null;
|
|
for (var i = 0; i < urls.length; i++) {
|
|
try {
|
|
var r = await fetchAPIWithRetry(urls[i]);
|
|
if (r) { data = r; break; }
|
|
} catch (e) {}
|
|
}
|
|
var html = '<div class="info-row"><div class="info-label">Contract</div><div class="info-value">' + explorerAddressLink(contractAddress, escapeHtml(contractAddress), 'color: inherit; text-decoration: none;') + '</div></div>';
|
|
html += '<div class="info-row"><div class="info-label">Token ID</div><div class="info-value">' + escapeHtml(String(tokenId)) + '</div></div>';
|
|
if (data) {
|
|
if (data.metadata && data.metadata.image) {
|
|
html += '<div class="info-row"><div class="info-label">Image</div><div class="info-value"><img src="' + escapeHtml(data.metadata.image) + '" alt="NFT" style="max-width: 200px; border-radius: 8px;" onerror="this.style.display=\'none\'"></div></div>';
|
|
}
|
|
if (data.name) html += '<div class="info-row"><div class="info-label">Name</div><div class="info-value">' + escapeHtml(data.name) + '</div></div>';
|
|
if (data.description) html += '<div class="info-row"><div class="info-label">Description</div><div class="info-value">' + escapeHtml(data.description) + '</div></div>';
|
|
if (data.owner) { var ownerAddr = (data.owner.hash || data.owner); html += '<div class="info-row"><div class="info-label">Owner</div><div class="info-value">' + explorerAddressLink(ownerAddr, escapeHtml(ownerAddr), 'color: inherit; text-decoration: none;') + '</div></div>'; }
|
|
if (data.metadata && data.metadata.attributes && Array.isArray(data.metadata.attributes)) {
|
|
html += '<div class="info-row"><div class="info-label">Traits</div><div class="info-value"><div style="display: flex; flex-wrap: wrap; gap: 0.5rem;">';
|
|
data.metadata.attributes.forEach(function(attr) {
|
|
var traitType = (attr.trait_type || attr.traitType || ''); var val = (attr.value != null ? attr.value : '');
|
|
if (traitType || val) html += '<span style="background: var(--light); padding: 0.25rem 0.5rem; border-radius: 6px; font-size: 0.85rem;">' + escapeHtml(traitType) + ': ' + escapeHtml(String(val)) + '</span>';
|
|
});
|
|
html += '</div></div></div>';
|
|
}
|
|
}
|
|
html += '<p style="margin-top: 1rem;"><a href="' + EXPLORER_ORIGIN + '/token/' + encodeURIComponent(contractAddress) + '/instance/' + encodeURIComponent(tokenId) + '" target="_blank" rel="noopener noreferrer" style="color: var(--primary);">View on Blockscout</a></p>';
|
|
container.innerHTML = html;
|
|
} catch (err) {
|
|
container.innerHTML = '<div class="error">Failed to load NFT: ' + escapeHtml(err.message || 'Unknown') + '</div>';
|
|
}
|
|
}
|
|
window.showNftDetail = showNftDetail;
|
|
|
|
function showSearchResultsList(items, query) {
|
|
showView('searchResults');
|
|
var container = document.getElementById('searchResultsContent');
|
|
if (!container) return;
|
|
var html = '<p style="color: var(--text-light); margin-bottom: 1rem;">Found ' + items.length + ' result(s) for "' + escapeHtml(query) + '". Click a row to open.</p>';
|
|
html += '<table class="table"><thead><tr><th>Type</th><th>Value</th></tr></thead><tbody>';
|
|
items.forEach(function(item) {
|
|
var type = (item.type || item.address_type || '').toLowerCase();
|
|
var label = item.name || item.symbol || item.address_hash || item.hash || item.tx_hash || (item.block_number != null ? 'Block #' + item.block_number : '') || '-';
|
|
var addr, txHash, blockNum, tokenAddr;
|
|
if (item.token_address || item.token_contract_address_hash) {
|
|
tokenAddr = item.token_address || item.token_contract_address_hash;
|
|
if (/^0x[a-f0-9]{40}$/i.test(tokenAddr)) {
|
|
html += '<tr style="cursor: pointer;" onclick="showTokenDetail(\'' + escapeHtml(tokenAddr) + '\')"><td>Token</td><td class="hash">' + escapeHtml(shortenHash(tokenAddr)) + ' ' + (item.name || item.symbol ? ' (' + escapeHtml(item.name || item.symbol) + ')' : '') + '</td></tr>';
|
|
return;
|
|
}
|
|
}
|
|
if (item.address_hash || item.hash) {
|
|
addr = item.address_hash || item.hash;
|
|
if (/^0x[a-f0-9]{40}$/i.test(addr)) {
|
|
html += '<tr><td>Address</td><td>' + explorerAddressLink(addr, escapeHtml(shortenHash(addr)), 'color: inherit; text-decoration: none;') + '</td></tr>';
|
|
return;
|
|
}
|
|
}
|
|
if (item.tx_hash || (item.hash && item.hash.length === 66)) {
|
|
txHash = item.tx_hash || item.hash;
|
|
if (/^0x[a-f0-9]{64}$/i.test(txHash)) {
|
|
html += '<tr><td>Transaction</td><td>' + explorerTransactionLink(txHash, escapeHtml(shortenHash(txHash)), 'color: inherit; text-decoration: none;') + '</td></tr>';
|
|
return;
|
|
}
|
|
}
|
|
if (item.block_number != null) {
|
|
blockNum = String(item.block_number);
|
|
html += '<tr><td>Block</td><td>' + explorerBlockLink(blockNum, '#' + escapeHtml(blockNum), 'color: inherit; text-decoration: none;') + '</td></tr>';
|
|
return;
|
|
}
|
|
html += '<tr><td>' + escapeHtml(type || 'Unknown') + '</td><td>' + escapeHtml(String(label).substring(0, 80)) + '</td></tr>';
|
|
});
|
|
html += '</tbody></table>';
|
|
container.innerHTML = html;
|
|
}
|
|
window.showSearchResultsList = showSearchResultsList;
|
|
|
|
async function handleSearch(query) {
|
|
query = query.trim();
|
|
if (!query) {
|
|
showToast('Please enter a search query', 'info');
|
|
return;
|
|
}
|
|
|
|
saveSmartSearchHistory(query);
|
|
closeSmartSearchModal();
|
|
const normalizedQuery = query.toLowerCase().replace(/\s/g, '');
|
|
|
|
try {
|
|
if (/^0x[a-f0-9]{40}$/i.test(normalizedQuery)) {
|
|
await showAddressDetail(normalizedQuery);
|
|
return;
|
|
}
|
|
if (/^0x[a-f0-9]{64}$/i.test(normalizedQuery)) {
|
|
await showTransactionDetail(normalizedQuery);
|
|
return;
|
|
}
|
|
if (/^\d+$/.test(query)) {
|
|
await showBlockDetail(query);
|
|
return;
|
|
}
|
|
if (/^0x[a-f0-9]+$/i.test(normalizedQuery)) {
|
|
const blockNum = parseInt(normalizedQuery, 16);
|
|
if (!isNaN(blockNum)) {
|
|
await showBlockDetail(blockNum.toString());
|
|
return;
|
|
}
|
|
}
|
|
|
|
if (CHAIN_ID === 138) {
|
|
var searchResults = null;
|
|
try {
|
|
searchResults = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/search?q=' + encodeURIComponent(query));
|
|
} catch (e) {}
|
|
if (searchResults && searchResults.items && searchResults.items.length > 0) {
|
|
showSearchResultsList(searchResults.items, query);
|
|
return;
|
|
}
|
|
if (/^0x[a-f0-9]{8,64}$/i.test(normalizedQuery)) {
|
|
try {
|
|
var txResp = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/transactions/' + normalizedQuery);
|
|
if (txResp && (txResp.hash || txResp.tx_hash)) {
|
|
var fullHash = txResp.hash || txResp.tx_hash;
|
|
await showTransactionDetail(fullHash);
|
|
return;
|
|
}
|
|
} catch (e) {}
|
|
showToast('No unique result for partial hash. Use at least 0x + 8 hex characters, or full tx hash (0x + 64 hex).', 'info');
|
|
return;
|
|
}
|
|
}
|
|
|
|
showToast('Invalid search. Try address (0x...40 hex), tx hash (0x...64 hex or 0x+8 hex), block number, or token/contract name.', 'error');
|
|
} catch (error) {
|
|
console.error('Search error:', error);
|
|
showToast('Failed to load search results: ' + (error.message || 'Unknown error'), 'error');
|
|
}
|
|
}
|
|
window.handleSearch = handleSearch;
|
|
|
|
function getTimeAgo(date) {
|
|
if (!date || !(date instanceof Date) || isNaN(date.getTime())) {
|
|
return 'N/A';
|
|
}
|
|
|
|
const now = new Date();
|
|
const diffMs = now - date;
|
|
const diffSecs = Math.floor(diffMs / 1000);
|
|
const diffMins = Math.floor(diffSecs / 60);
|
|
const diffHours = Math.floor(diffMins / 60);
|
|
const diffDays = Math.floor(diffHours / 24);
|
|
|
|
if (diffSecs < 60) {
|
|
return `${diffSecs}s ago`;
|
|
} else if (diffMins < 60) {
|
|
return `${diffMins}m ago`;
|
|
} else if (diffHours < 24) {
|
|
return `${diffHours}h ago`;
|
|
} else if (diffDays < 7) {
|
|
return `${diffDays}d ago`;
|
|
} else {
|
|
return date.toLocaleDateString();
|
|
}
|
|
}
|
|
|
|
function formatNumber(num) {
|
|
return parseInt(num || 0).toLocaleString();
|
|
}
|
|
|
|
function shortenHash(hash, length = 10) {
|
|
if (!hash) return 'N/A';
|
|
// Convert to string if it's not already
|
|
const hashStr = String(hash);
|
|
if (hashStr.length <= length * 2 + 2) return hashStr;
|
|
return hashStr.substring(0, length + 2) + '...' + hashStr.substring(hashStr.length - length);
|
|
}
|
|
|
|
function formatEther(wei, unit = 'ether') {
|
|
if (typeof wei === 'string' && wei.startsWith('0x')) {
|
|
wei = BigInt(wei);
|
|
}
|
|
const weiNum = typeof wei === 'bigint' ? Number(wei) : parseFloat(wei);
|
|
const ether = weiNum / Math.pow(10, unit === 'gwei' ? 9 : 18);
|
|
return ether.toFixed(6).replace(/\.?0+$/, '');
|
|
}
|
|
|
|
function getExplorerAIPageContext() {
|
|
return {
|
|
path: (window.location && window.location.pathname) ? window.location.pathname : '/',
|
|
view: currentView || 'home'
|
|
};
|
|
}
|
|
|
|
function renderExplorerAIMessages() {
|
|
var list = document.getElementById('explorerAIMessageList');
|
|
var status = document.getElementById('explorerAIStatus');
|
|
if (!list) return;
|
|
|
|
list.innerHTML = _explorerAIState.messages.map(function(message) {
|
|
var isAssistant = message.role === 'assistant';
|
|
var bubbleStyle = isAssistant
|
|
? 'background: rgba(37,99,235,0.10); border:1px solid rgba(37,99,235,0.18);'
|
|
: 'background: rgba(15,23,42,0.06); border:1px solid rgba(148,163,184,0.25);';
|
|
return '<div style="display:flex; justify-content:' + (isAssistant ? 'flex-start' : 'flex-end') + ';">' +
|
|
'<div style="max-width: 88%; padding: 0.85rem 0.95rem; border-radius: 16px; ' + bubbleStyle + '">' +
|
|
'<div style="font-size:0.72rem; letter-spacing:0.06em; text-transform:uppercase; color:var(--text-light); margin-bottom:0.35rem;">' + (isAssistant ? 'Explorer AI' : 'You') + '</div>' +
|
|
'<div style="white-space:pre-wrap; line-height:1.55;">' + escapeHtml(message.content || '') + '</div>' +
|
|
'</div>' +
|
|
'</div>';
|
|
}).join('');
|
|
|
|
if (_explorerAIState.loading) {
|
|
list.innerHTML += '<div style="display:flex; justify-content:flex-start;"><div style="padding:0.8rem 0.95rem; border-radius:16px; background:rgba(37,99,235,0.08); border:1px solid rgba(37,99,235,0.16); color:var(--text-light);">Thinking through indexed data, live routes, and docs...</div></div>';
|
|
}
|
|
|
|
list.scrollTop = list.scrollHeight;
|
|
if (status) {
|
|
status.textContent = _explorerAIState.loading
|
|
? 'Querying explorer data and the model...'
|
|
: 'Read-only assistant using indexed explorer data, route APIs, and curated docs.';
|
|
}
|
|
}
|
|
|
|
function setExplorerAIOpen(open) {
|
|
_explorerAIState.open = !!open;
|
|
var panel = document.getElementById('explorerAIPanel');
|
|
var button = document.getElementById('explorerAIFab');
|
|
if (panel) panel.style.display = open ? 'flex' : 'none';
|
|
if (button) button.setAttribute('aria-expanded', open ? 'true' : 'false');
|
|
if (open) {
|
|
renderExplorerAIMessages();
|
|
var input = document.getElementById('explorerAIInput');
|
|
if (input) setTimeout(function() { input.focus(); }, 30);
|
|
}
|
|
}
|
|
|
|
function toggleExplorerAIPanel(forceOpen) {
|
|
if (typeof forceOpen === 'boolean') {
|
|
setExplorerAIOpen(forceOpen);
|
|
return;
|
|
}
|
|
setExplorerAIOpen(!_explorerAIState.open);
|
|
}
|
|
window.toggleExplorerAIPanel = toggleExplorerAIPanel;
|
|
|
|
function buildExplorerAISourceSummary(context) {
|
|
if (!context || !Array.isArray(context.sources) || !context.sources.length) return '';
|
|
return context.sources.map(function(source) {
|
|
return source.label || source.type || 'source';
|
|
}).filter(Boolean).join(' | ');
|
|
}
|
|
|
|
async function submitExplorerAIMessage(prefill) {
|
|
var input = document.getElementById('explorerAIInput');
|
|
var raw = typeof prefill === 'string' ? prefill : (input ? input.value : '');
|
|
var question = String(raw || '').trim();
|
|
if (!question || _explorerAIState.loading) return;
|
|
|
|
_explorerAIState.messages.push({ role: 'user', content: question });
|
|
if (input) input.value = '';
|
|
_explorerAIState.loading = true;
|
|
renderExplorerAIMessages();
|
|
|
|
try {
|
|
var payload = {
|
|
messages: _explorerAIState.messages.slice(-8),
|
|
pageContext: getExplorerAIPageContext()
|
|
};
|
|
var response = await postJSON(EXPLORER_AI_API_BASE + '/chat', payload);
|
|
var reply = (response && response.reply) ? String(response.reply) : 'No reply returned.';
|
|
var sourceSummary = buildExplorerAISourceSummary(response && response.context);
|
|
if (sourceSummary) {
|
|
reply += '\n\nSources: ' + sourceSummary;
|
|
}
|
|
if (response && Array.isArray(response.warnings) && response.warnings.length) {
|
|
reply += '\n\nWarnings: ' + response.warnings.join(' | ');
|
|
}
|
|
_explorerAIState.messages.push({ role: 'assistant', content: reply });
|
|
} catch (error) {
|
|
_explorerAIState.messages.push({
|
|
role: 'assistant',
|
|
content: 'Explorer AI could not complete that request.\n\n' + (error && error.message ? error.message : 'Unknown error') + '\n\nIf this is production, confirm the backend has OPENAI_API_KEY and TOKEN_AGGREGATION_API_BASE configured.'
|
|
});
|
|
} finally {
|
|
_explorerAIState.loading = false;
|
|
renderExplorerAIMessages();
|
|
}
|
|
}
|
|
window.submitExplorerAIMessage = submitExplorerAIMessage;
|
|
|
|
function initExplorerAIPanel() {
|
|
if (document.getElementById('explorerAIPanel') || !document.body) return;
|
|
|
|
var style = document.createElement('style');
|
|
style.textContent = `
|
|
#explorerAIFab {
|
|
position: fixed;
|
|
right: 20px;
|
|
bottom: 20px;
|
|
z-index: 20010;
|
|
border: 0;
|
|
border-radius: 999px;
|
|
padding: 0.9rem 1rem;
|
|
background: linear-gradient(135deg, #0f172a, #2563eb);
|
|
color: #fff;
|
|
box-shadow: 0 16px 36px rgba(15,23,42,0.28);
|
|
cursor: pointer;
|
|
font-weight: 700;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
#explorerAIPanel {
|
|
position: fixed;
|
|
right: 20px;
|
|
bottom: 84px;
|
|
width: min(420px, calc(100vw - 24px));
|
|
height: min(72vh, 680px);
|
|
display: none;
|
|
flex-direction: column;
|
|
z-index: 20010;
|
|
background: var(--card-bg);
|
|
border: 1px solid var(--border);
|
|
border-radius: 22px;
|
|
box-shadow: 0 24px 60px rgba(15,23,42,0.25);
|
|
overflow: hidden;
|
|
}
|
|
#explorerAIPanel textarea {
|
|
width: 100%;
|
|
min-height: 88px;
|
|
resize: vertical;
|
|
border-radius: 14px;
|
|
border: 1px solid var(--border);
|
|
background: var(--light);
|
|
color: var(--text);
|
|
padding: 0.85rem 0.9rem;
|
|
font: inherit;
|
|
}
|
|
@media (max-width: 680px) {
|
|
#explorerAIPanel {
|
|
right: 12px;
|
|
left: 12px;
|
|
bottom: 76px;
|
|
width: auto;
|
|
height: min(74vh, 720px);
|
|
}
|
|
#explorerAIFab {
|
|
right: 12px;
|
|
bottom: 12px;
|
|
}
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
|
|
var button = document.createElement('button');
|
|
button.id = 'explorerAIFab';
|
|
button.type = 'button';
|
|
button.setAttribute('aria-expanded', 'false');
|
|
button.setAttribute('aria-controls', 'explorerAIPanel');
|
|
button.innerHTML = '<i class="fas fa-robot" aria-hidden="true" style="margin-right:0.45rem;"></i>Explorer AI';
|
|
button.addEventListener('click', function() { toggleExplorerAIPanel(); });
|
|
|
|
var panel = document.createElement('section');
|
|
panel.id = 'explorerAIPanel';
|
|
panel.setAttribute('aria-label', 'Explorer AI');
|
|
panel.innerHTML = '' +
|
|
'<div style="padding:1rem 1rem 0.85rem; border-bottom:1px solid var(--border); background:linear-gradient(180deg, rgba(37,99,235,0.10), rgba(37,99,235,0));">' +
|
|
'<div style="display:flex; align-items:flex-start; justify-content:space-between; gap:1rem;">' +
|
|
'<div>' +
|
|
'<div style="font-size:1rem; font-weight:800;">Explorer AI</div>' +
|
|
'<div id="explorerAIStatus" style="font-size:0.84rem; color:var(--text-light); margin-top:0.2rem;">Read-only assistant using indexed explorer data, route APIs, and curated docs.</div>' +
|
|
'</div>' +
|
|
'<button type="button" class="btn btn-secondary" style="padding:0.35rem 0.6rem;" onclick="toggleExplorerAIPanel(false)">Close</button>' +
|
|
'</div>' +
|
|
'<div style="display:flex; flex-wrap:wrap; gap:0.45rem; margin-top:0.85rem;">' +
|
|
'<button type="button" class="btn btn-secondary" style="padding:0.35rem 0.65rem;" onclick="submitExplorerAIMessage(\'Which Chain 138 routes are live right now?\')">Live routes</button>' +
|
|
'<button type="button" class="btn btn-secondary" style="padding:0.35rem 0.65rem;" onclick="submitExplorerAIMessage(\'Why would a route show partial instead of live?\')">Route status</button>' +
|
|
'<button type="button" class="btn btn-secondary" style="padding:0.35rem 0.65rem;" onclick="submitExplorerAIMessage(\'Summarize the current page context and what I can do next.\')">Current page</button>' +
|
|
'</div>' +
|
|
'</div>' +
|
|
'<div id="explorerAIMessageList" style="flex:1; overflow:auto; padding:1rem; display:grid; gap:0.7rem; background:linear-gradient(180deg, rgba(15,23,42,0.02), rgba(15,23,42,0));"></div>' +
|
|
'<div style="padding:1rem; border-top:1px solid var(--border); display:grid; gap:0.7rem;">' +
|
|
'<div style="font-size:0.78rem; color:var(--text-light);">Public explorer and route data only. No private key handling, no transaction execution.</div>' +
|
|
'<textarea id="explorerAIInput" placeholder="Ask about a tx hash, address, bridge path, liquidity pool, or route status..."></textarea>' +
|
|
'<div style="display:flex; justify-content:space-between; align-items:center; gap:0.75rem;">' +
|
|
'<div style="font-size:0.78rem; color:var(--text-light);">Shift+Enter for a new line. Enter to send.</div>' +
|
|
'<button type="button" class="btn btn-primary" id="explorerAISendBtn">Ask Explorer AI</button>' +
|
|
'</div>' +
|
|
'</div>';
|
|
|
|
document.body.appendChild(button);
|
|
document.body.appendChild(panel);
|
|
|
|
var input = document.getElementById('explorerAIInput');
|
|
var sendButton = document.getElementById('explorerAISendBtn');
|
|
if (sendButton) {
|
|
sendButton.addEventListener('click', function() {
|
|
submitExplorerAIMessage();
|
|
});
|
|
}
|
|
if (input) {
|
|
input.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
submitExplorerAIMessage();
|
|
}
|
|
});
|
|
}
|
|
|
|
renderExplorerAIMessages();
|
|
}
|
|
|
|
// Export functions
|
|
function exportBlockData(blockNumber) {
|
|
// Fetch block data and export as JSON
|
|
fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/blocks/${blockNumber}`)
|
|
.then(response => {
|
|
const block = normalizeBlock(response);
|
|
const dataStr = JSON.stringify(block, null, 2);
|
|
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
|
const url = URL.createObjectURL(dataBlob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = `block-${blockNumber}.json`;
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
})
|
|
.catch(error => {
|
|
alert('Failed to export block data: ' + error.message);
|
|
});
|
|
}
|
|
|
|
function exportTransactionData(txHash) {
|
|
// Fetch transaction data and export as JSON
|
|
fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/transactions/${txHash}`)
|
|
.then(response => {
|
|
const tx = normalizeTransaction(response);
|
|
const dataStr = JSON.stringify(tx, null, 2);
|
|
const dataBlob = new Blob([dataStr], { type: 'application/json' });
|
|
const url = URL.createObjectURL(dataBlob);
|
|
const link = document.createElement('a');
|
|
link.href = url;
|
|
link.download = `transaction-${txHash.substring(0, 10)}.json`;
|
|
link.click();
|
|
URL.revokeObjectURL(url);
|
|
})
|
|
.catch(error => {
|
|
alert('Failed to export transaction data: ' + error.message);
|
|
});
|
|
}
|
|
|
|
// Global error handler
|
|
window.addEventListener('error', (event) => {
|
|
console.error('Global error:', event.error);
|
|
if (typeof showToast === 'function') {
|
|
showToast('An error occurred. Please refresh the page.', 'error');
|
|
}
|
|
});
|
|
|
|
window.addEventListener('unhandledrejection', (event) => {
|
|
console.error('Unhandled promise rejection:', event.reason);
|
|
if (typeof showToast === 'function') {
|
|
showToast('A network error occurred. Please try again.', 'error');
|
|
}
|
|
});
|
|
|
|
function setLiveRegion(text) {
|
|
var el = document.getElementById('explorerLiveRegion');
|
|
if (el) el.textContent = text || '';
|
|
}
|
|
// Toast notification function
|
|
function showToast(message, type = 'info', duration = 3000) {
|
|
if (type === 'error') setLiveRegion(message);
|
|
const toast = document.createElement('div');
|
|
toast.className = `toast toast-${type}`;
|
|
toast.style.cssText = `
|
|
position: fixed;
|
|
top: 20px;
|
|
right: 20px;
|
|
padding: 1rem 1.5rem;
|
|
background: ${type === 'error' ? '#fee2e2' : type === 'success' ? '#d1fae5' : '#dbeafe'};
|
|
color: ${type === 'error' ? '#ef4444' : type === 'success' ? '#10b981' : '#3b82f6'};
|
|
border-radius: 8px;
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
z-index: 10000;
|
|
animation: slideIn 0.3s ease-out;
|
|
`;
|
|
toast.textContent = message;
|
|
document.body.appendChild(toast);
|
|
|
|
setTimeout(() => {
|
|
toast.style.animation = 'slideOut 0.3s ease-out';
|
|
setTimeout(() => toast.remove(), 300);
|
|
}, duration);
|
|
}
|
|
|
|
// Add CSS for toast animations
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
@keyframes slideIn {
|
|
from { transform: translateX(100%); opacity: 0; }
|
|
to { transform: translateX(0); opacity: 1; }
|
|
}
|
|
@keyframes slideOut {
|
|
from { transform: translateX(0); opacity: 1; }
|
|
to { transform: translateX(100%); opacity: 0; }
|
|
}
|
|
`;
|
|
document.head.appendChild(style);
|
|
|
|
// Search launcher, modal handlers, and mobile nav close on link click
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
initExplorerAIPanel();
|
|
const launchBtn = document.getElementById('searchLauncherBtn');
|
|
const modal = document.getElementById('smartSearchModal');
|
|
const backdrop = document.getElementById('smartSearchBackdrop');
|
|
const closeBtn = document.getElementById('smartSearchCloseBtn');
|
|
const input = document.getElementById('smartSearchInput');
|
|
const submitBtn = document.getElementById('smartSearchSubmitBtn');
|
|
if (launchBtn) {
|
|
launchBtn.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
openSmartSearchModal('');
|
|
});
|
|
}
|
|
if (closeBtn) {
|
|
closeBtn.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
closeSmartSearchModal();
|
|
});
|
|
}
|
|
if (backdrop) {
|
|
backdrop.addEventListener('click', closeSmartSearchModal);
|
|
}
|
|
if (input) {
|
|
input.addEventListener('input', function(e) {
|
|
updateSmartSearchPreview(e.target.value);
|
|
});
|
|
input.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape') {
|
|
e.preventDefault();
|
|
closeSmartSearchModal();
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
handleSearch(e.target.value);
|
|
}
|
|
});
|
|
}
|
|
if (submitBtn && input) {
|
|
submitBtn.addEventListener('click', function(e) {
|
|
e.preventDefault();
|
|
handleSearch(input.value);
|
|
});
|
|
}
|
|
window.addEventListener('keydown', function(e) {
|
|
var target = e.target;
|
|
var tag = target && target.tagName ? target.tagName.toLowerCase() : '';
|
|
var isEditable = !!(target && (target.isContentEditable || tag === 'input' || tag === 'textarea' || tag === 'select'));
|
|
if (e.key === 'Escape' && modal && modal.style.display === 'block') {
|
|
e.preventDefault();
|
|
closeSmartSearchModal();
|
|
return;
|
|
}
|
|
if ((e.key === '/' || ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k')) && !isEditable) {
|
|
e.preventDefault();
|
|
openSmartSearchModal('');
|
|
}
|
|
});
|
|
var navLinks = document.getElementById('navLinks');
|
|
if (navLinks) {
|
|
navLinks.addEventListener('click', function(e) {
|
|
if (e.target.closest('a')) closeNavMenu();
|
|
});
|
|
initNavDropdowns();
|
|
}
|
|
});
|