- React: response.ok checks (address, transaction, search); block number validation; stable Table keys; API modules (addresses, transactions, blocks normalizer) - SPA: escapeHtml/safe URLs/onclick; getRpcUrl in rpcCall; cancel blocks rAF on view change; named constants; hash route decode - SPA: createBlockCardHtml + normalizeBlockDisplay (L1); DEBUG console gating; aria-live for errors; token/block/tx detail escaping - Docs: FRONTEND_REVIEW.md, FRONTEND_TASKS_AND_REVIEW.md; favicons; .gitignore *.tsbuildinfo Co-authored-by: Cursor <cursoragent@cursor.com>
4179 lines
223 KiB
HTML
4179 lines
223 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
|
|
<meta http-equiv="Pragma" content="no-cache">
|
|
<meta http-equiv="Expires" content="0">
|
|
<!-- CSP: unsafe-eval required by ethers.js v5 UMD from CDN (uses new Function for ABI). Our code avoids eval/string setTimeout. -->
|
|
<meta http-equiv="Content-Security-Policy" content="script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.jsdelivr.net https://unpkg.com https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com; font-src 'self' https://cdnjs.cloudflare.com; img-src 'self' data: https:; connect-src 'self' https://explorer.d-bis.org https://rpc-http-pub.d-bis.org wss://rpc-ws-pub.d-bis.org http://192.168.11.221:8545 ws://192.168.11.221:8546;">
|
|
<title>SolaceScanScout | The Defi Oracle Meta Explorer | d-bis.org</title>
|
|
<meta name="description" content="SolaceScanScout - The Defi Oracle Meta Explorer. Comprehensive blockchain explorer for ChainID 138 with cross-chain bridge monitoring, WETH utilities, and real-time transaction tracking.">
|
|
<meta name="keywords" content="blockchain explorer, ChainID 138, CCIP bridge, WETH, DeFi Oracle, SolaceScanScout, blockchain, ethereum, blockscout">
|
|
<meta name="author" content="SolaceScanScout">
|
|
<meta name="application-name" content="SolaceScanScout">
|
|
<meta name="theme-color" content="#667eea">
|
|
|
|
<!-- Open Graph / Facebook -->
|
|
<meta property="og:type" content="website">
|
|
<meta property="og:url" content="https://explorer.d-bis.org/">
|
|
<meta property="og:title" content="SolaceScanScout - The Defi Oracle Meta Explorer">
|
|
<meta property="og:description" content="Comprehensive blockchain explorer for ChainID 138 with cross-chain bridge monitoring and WETH utilities.">
|
|
<meta property="og:image" content="https://explorer.d-bis.org/og-image.png">
|
|
<meta property="og:site_name" content="SolaceScanScout">
|
|
|
|
<!-- Twitter -->
|
|
<meta name="twitter:card" content="summary_large_image">
|
|
<meta name="twitter:url" content="https://explorer.d-bis.org/">
|
|
<meta name="twitter:title" content="SolaceScanScout - The Defi Oracle Meta Explorer">
|
|
<meta name="twitter:description" content="Comprehensive blockchain explorer for ChainID 138 with cross-chain bridge monitoring and WETH utilities.">
|
|
<meta name="twitter:image" content="https://explorer.d-bis.org/og-image.png">
|
|
|
|
<!-- Favicon -->
|
|
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
|
<!-- Load ethers.js with multiple fallback CDNs -->
|
|
<script>
|
|
(function() {
|
|
var ethersLoaded = false;
|
|
var cdnIndex = 0;
|
|
var cdns = [
|
|
'https://cdn.jsdelivr.net/npm/ethers@5.7.2/dist/ethers.umd.min.js',
|
|
'https://unpkg.com/ethers@5.7.2/dist/ethers.umd.min.js',
|
|
'https://cdnjs.cloudflare.com/ajax/libs/ethers/5.7.2/ethers.umd.min.js'
|
|
];
|
|
|
|
function loadEthers() {
|
|
if (ethersLoaded || typeof ethers !== 'undefined') {
|
|
window.ethersReady = true;
|
|
console.log('✅ Ethers.js library loaded successfully');
|
|
window.dispatchEvent(new Event('ethersReady'));
|
|
return;
|
|
}
|
|
|
|
if (cdnIndex >= cdns.length) {
|
|
console.error('❌ All ethers.js CDNs failed to load');
|
|
// Try one more time with a different approach
|
|
setTimeout(function() {
|
|
if (typeof ethers === 'undefined') {
|
|
console.error('Ethers.js failed to load from all sources');
|
|
} else {
|
|
window.ethersReady = true;
|
|
window.dispatchEvent(new Event('ethersReady'));
|
|
}
|
|
}, 1000);
|
|
return;
|
|
}
|
|
|
|
var script = document.createElement('script');
|
|
script.src = cdns[cdnIndex];
|
|
script.async = false; // Load synchronously to ensure it's available
|
|
|
|
script.onload = function() {
|
|
// Double check after load
|
|
setTimeout(function() {
|
|
if (typeof ethers !== 'undefined') {
|
|
ethersLoaded = true;
|
|
window.ethersReady = true;
|
|
console.log('✅ Ethers.js loaded from:', cdns[cdnIndex]);
|
|
window.dispatchEvent(new Event('ethersReady'));
|
|
} else {
|
|
console.warn('Script loaded but ethers not defined, trying next CDN...');
|
|
cdnIndex++;
|
|
loadEthers();
|
|
}
|
|
}, 100);
|
|
};
|
|
|
|
script.onerror = function() {
|
|
console.warn('Failed to load from:', cdns[cdnIndex]);
|
|
cdnIndex++;
|
|
loadEthers();
|
|
};
|
|
|
|
document.head.appendChild(script);
|
|
}
|
|
|
|
// Start loading immediately
|
|
loadEthers();
|
|
|
|
// Also check periodically in case it loads via another method
|
|
var checkInterval = setInterval(function() {
|
|
if (typeof ethers !== 'undefined' && !ethersLoaded) {
|
|
clearInterval(checkInterval);
|
|
ethersLoaded = true;
|
|
window.ethersReady = true;
|
|
console.log('✅ Ethers.js detected');
|
|
window.dispatchEvent(new Event('ethersReady'));
|
|
}
|
|
}, 100);
|
|
|
|
// Clear interval after 20 seconds
|
|
setTimeout(function() {
|
|
clearInterval(checkInterval);
|
|
}, 20000);
|
|
})();
|
|
</script>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
:root {
|
|
--primary: #667eea;
|
|
--secondary: #764ba2;
|
|
--success: #10b981;
|
|
--warning: #f59e0b;
|
|
--danger: #ef4444;
|
|
--bridge-blue: #3b82f6;
|
|
--dark: #1f2937;
|
|
--light: #f9fafb;
|
|
--border: #e5e7eb;
|
|
--text: #111827;
|
|
--text-light: #6b7280;
|
|
}
|
|
body.dark-theme {
|
|
--light: #111827;
|
|
--border: #374151;
|
|
--text: #f9fafb;
|
|
--text-light: #9ca3af;
|
|
background: #0f172a;
|
|
color: var(--text);
|
|
}
|
|
body.dark-theme .stat-card,
|
|
body.dark-theme .card,
|
|
body.dark-theme .block-card {
|
|
background: #1e293b;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
}
|
|
body.dark-theme .table th { background: #334155; color: var(--text); }
|
|
body.dark-theme .table tr:hover { background: #1e293b; }
|
|
body.dark-theme .skeleton { background: linear-gradient(90deg, #334155 25%, #475569 50%, #334155 75%); }
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
|
background: var(--light);
|
|
color: var(--text);
|
|
line-height: 1.6;
|
|
}
|
|
.navbar {
|
|
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
|
|
color: white;
|
|
padding: 1rem 2rem;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
|
position: sticky;
|
|
top: 0;
|
|
z-index: 1000;
|
|
}
|
|
.nav-container {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.logo {
|
|
font-size: 1.5rem;
|
|
font-weight: bold;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
.nav-links {
|
|
display: flex;
|
|
gap: 2rem;
|
|
list-style: none;
|
|
}
|
|
.nav-links a {
|
|
color: white;
|
|
text-decoration: none;
|
|
transition: opacity 0.2s;
|
|
}
|
|
.nav-links a:hover { opacity: 0.8; }
|
|
.search-box {
|
|
flex: 1;
|
|
max-width: 600px;
|
|
margin: 0 2rem;
|
|
}
|
|
.search-input {
|
|
width: 100%;
|
|
padding: 0.75rem 1rem;
|
|
border: none;
|
|
border-radius: 8px;
|
|
font-size: 1rem;
|
|
background: rgba(255,255,255,0.2);
|
|
color: white;
|
|
backdrop-filter: blur(10px);
|
|
}
|
|
.search-input::placeholder { color: rgba(255,255,255,0.7); }
|
|
.search-input:focus {
|
|
outline: none;
|
|
background: rgba(255,255,255,0.3);
|
|
}
|
|
.container {
|
|
max-width: 1400px;
|
|
margin: 0 auto;
|
|
padding: 2rem;
|
|
}
|
|
.stats-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
|
gap: 1rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.stat-card {
|
|
background: white;
|
|
padding: 1rem;
|
|
border-radius: 12px;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
transition: transform 0.2s, box-shadow 0.2s;
|
|
}
|
|
|
|
/* Block animation */
|
|
@keyframes blockPulse {
|
|
0%, 100% { transform: scale(1); opacity: 1; }
|
|
50% { transform: scale(1.02); opacity: 0.95; }
|
|
}
|
|
|
|
@keyframes slideInFromTop {
|
|
from {
|
|
opacity: 0;
|
|
transform: translateY(-10px);
|
|
}
|
|
to {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
}
|
|
|
|
.table tbody tr {
|
|
animation: slideInFromTop 0.3s ease-out;
|
|
}
|
|
|
|
.table tbody tr.new-block {
|
|
animation: blockPulse 1s ease-in-out;
|
|
background: rgba(74, 144, 226, 0.05);
|
|
}
|
|
|
|
.table tbody tr.new-transaction {
|
|
animation: slideInFromTop 0.5s ease-out;
|
|
background: rgba(74, 144, 226, 0.05);
|
|
}
|
|
|
|
/* Horizontal scrolling blocks */
|
|
.blocks-scroll-container {
|
|
display: flex;
|
|
overflow-x: hidden;
|
|
overflow-y: hidden;
|
|
gap: 1rem;
|
|
padding: 0.5rem 0;
|
|
scroll-behavior: smooth;
|
|
-webkit-overflow-scrolling: touch;
|
|
scrollbar-width: none;
|
|
position: relative;
|
|
width: 100%;
|
|
}
|
|
|
|
.blocks-scroll-container::-webkit-scrollbar {
|
|
display: none;
|
|
}
|
|
|
|
.blocks-scroll-content {
|
|
display: flex;
|
|
gap: 1rem;
|
|
will-change: transform;
|
|
}
|
|
|
|
.blocks-scroll-container::-webkit-scrollbar {
|
|
height: 8px;
|
|
}
|
|
|
|
.blocks-scroll-container::-webkit-scrollbar-track {
|
|
background: var(--light);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.blocks-scroll-container::-webkit-scrollbar-thumb {
|
|
background: var(--primary);
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.blocks-scroll-container::-webkit-scrollbar-thumb:hover {
|
|
background: var(--primary-dark);
|
|
}
|
|
|
|
.block-card {
|
|
min-width: 200px;
|
|
max-width: 200px;
|
|
background: white;
|
|
border-radius: 8px;
|
|
padding: 1rem;
|
|
box-shadow: 0 2px 6px rgba(0,0,0,0.1);
|
|
cursor: pointer;
|
|
transition: transform 0.2s, box-shadow 0.2s;
|
|
border: 2px solid transparent;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.block-card:hover {
|
|
transform: translateY(-4px);
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
border-color: var(--primary);
|
|
}
|
|
|
|
.block-card.new-block {
|
|
animation: blockPulse 1s ease-in-out;
|
|
border-color: var(--primary);
|
|
background: rgba(74, 144, 226, 0.05);
|
|
}
|
|
|
|
.block-number {
|
|
font-size: 1.25rem;
|
|
font-weight: bold;
|
|
color: var(--primary);
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.block-hash {
|
|
font-size: 0.75rem;
|
|
color: var(--text-light);
|
|
font-family: monospace;
|
|
margin-bottom: 0.5rem;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.block-info {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.25rem;
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.block-info-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
color: var(--text);
|
|
}
|
|
|
|
.block-info-label {
|
|
color: var(--text-light);
|
|
font-size: 0.75rem;
|
|
}
|
|
|
|
.block-info-value {
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
}
|
|
.stat-card:hover {
|
|
transform: translateY(-2px);
|
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
|
}
|
|
.stat-card.bridge-card {
|
|
border-left: 4px solid var(--bridge-blue);
|
|
}
|
|
.stat-label {
|
|
color: var(--text-light);
|
|
font-size: 0.875rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.5px;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.stat-value {
|
|
font-size: 2rem;
|
|
font-weight: bold;
|
|
color: var(--primary);
|
|
}
|
|
.stat-value.bridge-value {
|
|
color: var(--bridge-blue);
|
|
}
|
|
.card {
|
|
background: white;
|
|
border-radius: 12px;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
|
|
padding: 1.25rem;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.card-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 0.75rem;
|
|
padding-bottom: 0.75rem;
|
|
border-bottom: 2px solid var(--border);
|
|
}
|
|
.card-title {
|
|
font-size: 1.5rem;
|
|
font-weight: bold;
|
|
color: var(--text);
|
|
}
|
|
.tabs {
|
|
display: flex;
|
|
gap: 1rem;
|
|
margin-bottom: 1.5rem;
|
|
border-bottom: 2px solid var(--border);
|
|
flex-wrap: wrap;
|
|
}
|
|
.tab {
|
|
padding: 1rem 1.5rem;
|
|
background: none;
|
|
border: none;
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
color: var(--text-light);
|
|
border-bottom: 3px solid transparent;
|
|
transition: all 0.2s;
|
|
}
|
|
.tab.active {
|
|
color: var(--primary);
|
|
border-bottom-color: var(--primary);
|
|
font-weight: 600;
|
|
}
|
|
.bridge-tab.active {
|
|
color: var(--bridge-blue);
|
|
border-bottom-color: var(--bridge-blue);
|
|
}
|
|
.weth-tab.active {
|
|
color: var(--success);
|
|
border-bottom-color: var(--success);
|
|
}
|
|
.table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
}
|
|
.table th {
|
|
text-align: left;
|
|
padding: 0.5rem 0.75rem;
|
|
background: var(--light);
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
border-bottom: 2px solid var(--border);
|
|
font-size: 0.875rem;
|
|
}
|
|
.table td {
|
|
padding: 0.5rem 0.75rem;
|
|
border-bottom: 1px solid var(--border);
|
|
font-size: 0.875rem;
|
|
}
|
|
.table tr:hover { background: var(--light); }
|
|
.hash {
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 0.875rem;
|
|
color: var(--primary);
|
|
word-break: break-all;
|
|
}
|
|
.hash:hover { text-decoration: underline; cursor: pointer; }
|
|
.badge {
|
|
display: inline-block;
|
|
padding: 0.25rem 0.75rem;
|
|
border-radius: 20px;
|
|
font-size: 0.875rem;
|
|
font-weight: 600;
|
|
}
|
|
.badge-success { background: #d1fae5; color: var(--success); }
|
|
.badge-warning { background: #fef3c7; color: var(--warning); }
|
|
.badge-danger { background: #fee2e2; color: var(--danger); }
|
|
.badge-chain {
|
|
background: #dbeafe;
|
|
color: var(--bridge-blue);
|
|
}
|
|
.loading {
|
|
text-align: center;
|
|
padding: 3rem;
|
|
color: var(--text-light);
|
|
}
|
|
.loading i {
|
|
font-size: 2rem;
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
@keyframes spin {
|
|
from { transform: rotate(0deg); }
|
|
to { transform: rotate(360deg); }
|
|
}
|
|
/* Skeleton Loaders */
|
|
.skeleton {
|
|
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
|
background-size: 200% 100%;
|
|
animation: loading 1.5s ease-in-out infinite;
|
|
border-radius: 4px;
|
|
}
|
|
@keyframes loading {
|
|
0% { background-position: 200% 0; }
|
|
100% { background-position: -200% 0; }
|
|
}
|
|
.skeleton-text {
|
|
height: 1em;
|
|
margin: 0.5rem 0;
|
|
}
|
|
.skeleton-title {
|
|
height: 1.5em;
|
|
width: 60%;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.skeleton-table-row {
|
|
height: 3rem;
|
|
margin: 0.5rem 0;
|
|
}
|
|
/* Breadcrumb Navigation */
|
|
.breadcrumb {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
padding: 1rem 0;
|
|
margin-bottom: 1rem;
|
|
font-size: 0.9rem;
|
|
}
|
|
.breadcrumb a {
|
|
color: var(--primary);
|
|
text-decoration: none;
|
|
transition: opacity 0.2s;
|
|
}
|
|
.breadcrumb a:hover {
|
|
opacity: 0.7;
|
|
}
|
|
.breadcrumb-separator {
|
|
color: var(--text-light);
|
|
}
|
|
.breadcrumb-current {
|
|
color: var(--text);
|
|
font-weight: 600;
|
|
}
|
|
.error {
|
|
background: #fee2e2;
|
|
color: var(--danger);
|
|
padding: 1rem;
|
|
border-radius: 8px;
|
|
margin: 1rem 0;
|
|
}
|
|
.success {
|
|
background: #d1fae5;
|
|
color: var(--success);
|
|
padding: 1rem;
|
|
border-radius: 8px;
|
|
margin: 1rem 0;
|
|
}
|
|
.bridge-chain-card {
|
|
background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%);
|
|
padding: 1.5rem;
|
|
border-radius: 12px;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.weth-card {
|
|
background: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
|
|
padding: 1.5rem;
|
|
border-radius: 12px;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
.weth-form {
|
|
background: white;
|
|
padding: 1.5rem;
|
|
border-radius: 8px;
|
|
margin-top: 1rem;
|
|
}
|
|
.form-group {
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
.form-label {
|
|
display: block;
|
|
margin-bottom: 0.5rem;
|
|
font-weight: 600;
|
|
color: var(--text);
|
|
}
|
|
.form-input {
|
|
width: 100%;
|
|
padding: 0.75rem;
|
|
border: 2px solid var(--border);
|
|
border-radius: 8px;
|
|
font-size: 1rem;
|
|
transition: border-color 0.2s;
|
|
}
|
|
.form-input:focus {
|
|
outline: none;
|
|
border-color: var(--primary);
|
|
}
|
|
.form-input-group {
|
|
display: flex;
|
|
gap: 0.5rem;
|
|
}
|
|
.form-input-group .form-input {
|
|
flex: 1;
|
|
}
|
|
.btn {
|
|
padding: 0.75rem 1.5rem;
|
|
border: none;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
font-size: 1rem;
|
|
font-weight: 600;
|
|
transition: all 0.2s;
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
.btn-primary {
|
|
background: var(--primary);
|
|
color: white;
|
|
}
|
|
.btn-primary:hover { background: var(--secondary); }
|
|
.btn-bridge {
|
|
background: var(--bridge-blue);
|
|
color: white;
|
|
}
|
|
.btn-bridge:hover { background: #2563eb; }
|
|
.btn-success {
|
|
background: var(--success);
|
|
color: white;
|
|
}
|
|
.btn-success:hover { background: #059669; }
|
|
.btn-warning {
|
|
background: var(--warning);
|
|
color: white;
|
|
}
|
|
.btn-warning:hover { background: #d97706; }
|
|
.btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
.balance-display {
|
|
background: var(--light);
|
|
padding: 1rem;
|
|
border-radius: 8px;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.balance-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 0.5rem 0;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.balance-row:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.balance-label {
|
|
color: var(--text-light);
|
|
}
|
|
.balance-value {
|
|
font-weight: bold;
|
|
color: var(--text);
|
|
}
|
|
.chain-name {
|
|
font-size: 1.25rem;
|
|
font-weight: bold;
|
|
color: var(--bridge-blue);
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
.sr-only {
|
|
position: absolute;
|
|
width: 1px;
|
|
height: 1px;
|
|
padding: 0;
|
|
margin: -1px;
|
|
overflow: hidden;
|
|
clip: rect(0, 0, 0, 0);
|
|
white-space: nowrap;
|
|
border-width: 0;
|
|
}
|
|
.chain-info {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
|
gap: 1rem;
|
|
margin-top: 1rem;
|
|
}
|
|
.chain-stat {
|
|
font-size: 0.875rem;
|
|
}
|
|
.chain-stat-label {
|
|
color: var(--text-light);
|
|
}
|
|
.chain-stat-value {
|
|
font-weight: bold;
|
|
color: var(--text);
|
|
margin-top: 0.25rem;
|
|
}
|
|
.detail-view {
|
|
display: none;
|
|
}
|
|
.detail-view.active { display: block; }
|
|
.info-row {
|
|
display: flex;
|
|
padding: 1rem;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
.info-label {
|
|
font-weight: 600;
|
|
min-width: 200px;
|
|
color: var(--text-light);
|
|
}
|
|
.info-value {
|
|
flex: 1;
|
|
word-break: break-all;
|
|
}
|
|
.metamask-status {
|
|
padding: 1rem;
|
|
border-radius: 8px;
|
|
margin-bottom: 1rem;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
.metamask-status.connected {
|
|
background: #d1fae5;
|
|
color: var(--success);
|
|
}
|
|
.metamask-status.disconnected {
|
|
background: #fee2e2;
|
|
color: var(--danger);
|
|
}
|
|
.nav-toggle { display: none; background: none; border: none; color: white; padding: 0.5rem; cursor: pointer; font-size: 1.5rem; }
|
|
.nav-toggle:focus { outline: 2px solid rgba(255,255,255,0.5); }
|
|
@media (max-width: 900px) {
|
|
.nav-toggle { display: block; }
|
|
.nav-links { display: none; flex-direction: column; position: absolute; top: 100%; left: 0; right: 0; background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); padding: 1rem; gap: 0.5rem; box-shadow: 0 4px 12px rgba(0,0,0,0.15); z-index: 999; }
|
|
.nav-links.nav-open { display: flex; }
|
|
.nav-container { position: relative; flex-wrap: wrap; }
|
|
}
|
|
@media (max-width: 768px) {
|
|
.nav-container { flex-direction: column; gap: 1rem; align-items: stretch; }
|
|
.search-box { max-width: 100%; margin: 0; }
|
|
.nav-links { flex-wrap: wrap; justify-content: center; }
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<nav class="navbar">
|
|
<div class="nav-container">
|
|
<div class="logo">
|
|
<i class="fas fa-cube"></i>
|
|
<div style="display: flex; flex-direction: column; gap: 0.25rem;">
|
|
<span>SolaceScanScout</span>
|
|
<span style="font-size: 0.75rem; font-weight: normal; opacity: 0.9;">The Defi Oracle Meta Explorer</span>
|
|
</div>
|
|
</div>
|
|
<div class="search-box" style="display: flex; gap: 0.5rem;">
|
|
<label for="searchInput" class="sr-only">Search blockchain explorer</label>
|
|
<input type="text" class="search-input" id="searchInput" placeholder="Address, tx hash, block number, or token/contract name..." aria-label="Search blockchain explorer" aria-describedby="search-help-text" style="flex: 1;">
|
|
<button id="searchBtn" class="btn btn-primary" style="padding: 0.5rem 1rem; white-space: nowrap;" aria-label="Search">
|
|
<i class="fas fa-search"></i>
|
|
</button>
|
|
<span id="search-help-text" class="sr-only">Search by address (0x...40 hex), transaction hash (0x...64 hex), block number, or token/contract name</span>
|
|
</div>
|
|
<button type="button" class="nav-toggle" id="navToggle" aria-label="Toggle menu" aria-expanded="false" onclick="toggleNavMenu()"><i class="fas fa-bars" id="navToggleIcon"></i></button>
|
|
<ul class="nav-links" id="navLinks">
|
|
<li><a href="#/home" onclick="showHome();" aria-label="Navigate to home page"><i class="fas fa-home" aria-hidden="true"></i> Home</a></li>
|
|
<li><a href="#/blocks" onclick="showBlocks();" aria-label="View all blocks"><i class="fas fa-cubes" aria-hidden="true"></i> Blocks</a></li>
|
|
<li><a href="#/transactions" onclick="showTransactions();" aria-label="View all transactions"><i class="fas fa-exchange-alt" aria-hidden="true"></i> Transactions</a></li>
|
|
<li><a href="#/bridge" onclick="showBridgeMonitoring();" aria-label="View bridge monitoring"><i class="fas fa-bridge" aria-hidden="true"></i> Bridge</a></li>
|
|
<li><a href="#/weth" onclick="showWETHUtilities();" aria-label="View WETH utilities"><i class="fas fa-coins" aria-hidden="true"></i> WETH</a></li>
|
|
<li><a href="#/tokens" onclick="if(typeof focusSearchWithHint==='function')focusSearchWithHint('token');else showHome();" aria-label="View token list"><i class="fas fa-tag" aria-hidden="true"></i> Tokens</a></li>
|
|
<li id="analyticsNav" style="display: none;"><a href="#/analytics" onclick="showAnalytics();" aria-label="View analytics"><i class="fas fa-chart-line" aria-hidden="true"></i> Analytics</a></li>
|
|
<li id="operatorNav" style="display: none;"><a href="#/operator" onclick="showOperator();" aria-label="View operator panel"><i class="fas fa-cog" aria-hidden="true"></i> Operator</a></li>
|
|
</ul>
|
|
<div style="display: flex; align-items: center; gap: 0.75rem;">
|
|
<button id="themeToggle" type="button" class="btn btn-primary" onclick="toggleDarkMode()" style="padding: 0.5rem 0.75rem; background: rgba(255,255,255,0.2);" aria-label="Toggle dark mode" title="Toggle dark/light theme"><i class="fas fa-moon" id="themeIcon"></i></button>
|
|
<div id="walletConnect" style="display: flex; align-items: center; gap: 0.5rem;">
|
|
<button id="walletConnectBtn" class="btn btn-primary" onclick="connectWallet()" style="display: none;" aria-label="Connect wallet">Connect Wallet</button>
|
|
<div id="walletStatus" style="display: none; padding: 0.5rem 1rem; background: var(--success); color: white; border-radius: 8px; font-size: 0.875rem;">
|
|
<i class="fas fa-wallet"></i> <span id="walletAddress"></span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</nav>
|
|
|
|
<div id="explorerLiveRegion" aria-live="polite" aria-atomic="true" class="sr-only" role="status"></div>
|
|
<div class="container" id="mainContent">
|
|
<!-- Home View -->
|
|
<div id="homeView">
|
|
<div class="stats-grid" id="statsGrid">
|
|
<!-- Stats loaded dynamically -->
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Latest Blocks</h2>
|
|
<button class="btn btn-primary" onclick="showBlocks()" aria-label="View all blocks">View All</button>
|
|
</div>
|
|
<div id="latestBlocks">
|
|
<div class="loading"><i class="fas fa-spinner"></i> Loading blocks...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">Latest Transactions</h2>
|
|
<button class="btn btn-primary" onclick="showTransactions()" aria-label="View all transactions">View All</button>
|
|
</div>
|
|
<div id="latestTransactions">
|
|
<div class="loading"><i class="fas fa-spinner"></i> Loading transactions...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- WETH Utilities View -->
|
|
<div id="wethView" class="detail-view">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title"><i class="fas fa-coins"></i> WETH9 & WETH10 Utilities</h2>
|
|
<button class="btn btn-success" onclick="refreshWETHBalances()" aria-label="Refresh WETH balances"><i class="fas fa-sync-alt" aria-hidden="true"></i> Refresh</button>
|
|
</div>
|
|
|
|
<!-- MetaMask Connection Status -->
|
|
<div id="metamaskStatus" class="metamask-status disconnected">
|
|
<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>
|
|
</div>
|
|
|
|
<div class="tabs">
|
|
<button class="tab weth-tab active" onclick="showWETHTab('weth9', this)" aria-label="Switch to WETH9 tab" role="tab" aria-selected="true">WETH9</button>
|
|
<button class="tab weth-tab" onclick="showWETHTab('weth10', this)" aria-label="Switch to WETH10 tab" role="tab" aria-selected="false">WETH10</button>
|
|
<button class="tab weth-tab" onclick="showWETHTab('info', this)" aria-label="Switch to information tab" role="tab" aria-selected="false">Information</button>
|
|
</div>
|
|
|
|
<!-- WETH9 Tab -->
|
|
<div id="weth9Tab" class="weth-tab-content">
|
|
<div class="weth-card">
|
|
<div class="chain-name">WETH9 Token</div>
|
|
<div style="color: var(--text-light); margin-bottom: 1rem;">
|
|
Contract: <span class="hash" onclick="showAddressDetail('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2')" style="cursor: pointer;">0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2</span>
|
|
</div>
|
|
|
|
<div class="balance-display" id="weth9Balance">
|
|
<div class="balance-row">
|
|
<span class="balance-label">ETH Balance:</span>
|
|
<span class="balance-value" id="weth9EthBalance">-</span>
|
|
</div>
|
|
<div class="balance-row">
|
|
<span class="balance-label">WETH9 Balance:</span>
|
|
<span class="balance-value" id="weth9TokenBalance">-</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="weth-form">
|
|
<h3 style="margin-bottom: 1rem;">Wrap ETH → WETH9</h3>
|
|
<div class="form-group">
|
|
<label for="weth9WrapAmount" class="form-label">Amount (ETH)</label>
|
|
<div class="form-input-group">
|
|
<input type="number" class="form-input" id="weth9WrapAmount" placeholder="0.0" step="0.000001" min="0" aria-label="WETH9 wrap amount">
|
|
<button class="btn btn-primary" onclick="setMaxWETH9('wrap')" aria-label="Set maximum WETH9 wrap amount">MAX</button>
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-success" onclick="wrapWETH9()" id="weth9WrapBtn" disabled aria-label="Wrap ETH to WETH9">
|
|
<i class="fas fa-arrow-right"></i> Wrap ETH to WETH9
|
|
</button>
|
|
</div>
|
|
|
|
<div class="weth-form">
|
|
<h3 style="margin-bottom: 1rem;">Unwrap WETH9 → ETH</h3>
|
|
<div class="form-group">
|
|
<label for="weth9UnwrapAmount" class="form-label">Amount (WETH9)</label>
|
|
<div class="form-input-group">
|
|
<input type="number" class="form-input" id="weth9UnwrapAmount" placeholder="0.0" step="0.000001" min="0" aria-label="WETH9 unwrap amount">
|
|
<button class="btn btn-primary" onclick="setMaxWETH9('unwrap')" aria-label="Set maximum WETH9 unwrap amount">MAX</button>
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-warning" onclick="unwrapWETH9()" id="weth9UnwrapBtn" disabled aria-label="Unwrap WETH9 to ETH">
|
|
<i class="fas fa-arrow-left"></i> Unwrap WETH9 to ETH
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- WETH10 Tab -->
|
|
<div id="weth10Tab" class="weth-tab-content" style="display: none;">
|
|
<div class="weth-card">
|
|
<div class="chain-name">WETH10 Token</div>
|
|
<div style="color: var(--text-light); margin-bottom: 1rem;">
|
|
Contract: <span class="hash" onclick="showAddressDetail('0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f')" style="cursor: pointer;">0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f</span>
|
|
</div>
|
|
|
|
<div class="balance-display" id="weth10Balance">
|
|
<div class="balance-row">
|
|
<span class="balance-label">ETH Balance:</span>
|
|
<span class="balance-value" id="weth10EthBalance">-</span>
|
|
</div>
|
|
<div class="balance-row">
|
|
<span class="balance-label">WETH10 Balance:</span>
|
|
<span class="balance-value" id="weth10TokenBalance">-</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="weth-form">
|
|
<h3 style="margin-bottom: 1rem;">Wrap ETH → WETH10</h3>
|
|
<div class="form-group">
|
|
<label for="weth10WrapAmount" class="form-label">Amount (ETH)</label>
|
|
<div class="form-input-group">
|
|
<input type="number" class="form-input" id="weth10WrapAmount" placeholder="0.0" step="0.000001" min="0" aria-label="WETH10 wrap amount">
|
|
<button class="btn btn-primary" onclick="setMaxWETH10('wrap')" aria-label="Set maximum WETH10 wrap amount">MAX</button>
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-success" onclick="wrapWETH10()" id="weth10WrapBtn" disabled aria-label="Wrap ETH to WETH10">
|
|
<i class="fas fa-arrow-right"></i> Wrap ETH to WETH10
|
|
</button>
|
|
</div>
|
|
|
|
<div class="weth-form">
|
|
<h3 style="margin-bottom: 1rem;">Unwrap WETH10 → ETH</h3>
|
|
<div class="form-group">
|
|
<label for="weth10UnwrapAmount" class="form-label">Amount (WETH10)</label>
|
|
<div class="form-input-group">
|
|
<input type="number" class="form-input" id="weth10UnwrapAmount" placeholder="0.0" step="0.000001" min="0" aria-label="WETH10 unwrap amount">
|
|
<button class="btn btn-primary" onclick="setMaxWETH10('unwrap')" aria-label="Set maximum WETH10 unwrap amount">MAX</button>
|
|
</div>
|
|
</div>
|
|
<button class="btn btn-warning" onclick="unwrapWETH10()" id="weth10UnwrapBtn" disabled aria-label="Unwrap WETH10 to ETH">
|
|
<i class="fas fa-arrow-left"></i> Unwrap WETH10 to ETH
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Information Tab -->
|
|
<div id="wethInfoTab" class="weth-tab-content" style="display: none;">
|
|
<div class="card">
|
|
<h3>About WETH9 and WETH10</h3>
|
|
<div style="margin-top: 1rem; line-height: 1.8;">
|
|
<p><strong>WETH9</strong> and <strong>WETH10</strong> are wrapped versions of native ETH (Ether) that allow you to use ETH in smart contracts and DeFi protocols.</p>
|
|
|
|
<h4 style="margin-top: 1.5rem; margin-bottom: 0.5rem;">What is Wrapping?</h4>
|
|
<p>Wrapping ETH converts your native ETH into an ERC-20 token (WETH9 or WETH10) that can be used in DeFi applications, smart contracts, and cross-chain bridging.</p>
|
|
|
|
<h4 style="margin-top: 1.5rem; margin-bottom: 0.5rem;">Contract Addresses</h4>
|
|
<ul style="margin-left: 2rem; margin-top: 0.5rem;">
|
|
<li><strong>WETH9:</strong> <span class="hash">0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2</span></li>
|
|
<li><strong>WETH10:</strong> <span class="hash">0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f</span></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>Connect your MetaMask wallet</li>
|
|
<li>Select WETH9 or WETH10 tab</li>
|
|
<li>Enter the amount to wrap or unwrap</li>
|
|
<li>Confirm the transaction in MetaMask</li>
|
|
</ol>
|
|
|
|
<h4 style="margin-top: 1.5rem; margin-bottom: 0.5rem;">Cross-Chain Bridging</h4>
|
|
<p>Both WETH9 and WETH10 can be bridged to other chains using the CCIP bridge contracts:</p>
|
|
<ul style="margin-left: 2rem; margin-top: 0.5rem;">
|
|
<li><strong>WETH9 Bridge:</strong> <span class="hash">0x89dd12025bfCD38A168455A44B400e913ED33BE2</span></li>
|
|
<li><strong>WETH10 Bridge:</strong> <span class="hash">0xe0E93247376aa097dB308B92e6Ba36bA015535D0</span></li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bridge Monitoring View -->
|
|
<div id="bridgeView" class="detail-view">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title"><i class="fas fa-bridge"></i> Bridge Monitoring</h2>
|
|
<button class="btn btn-primary" onclick="refreshBridgeData()" aria-label="Refresh bridge data"><i class="fas fa-sync-alt" aria-hidden="true"></i> Refresh</button>
|
|
</div>
|
|
<div id="bridgeContent">
|
|
<div class="loading"><i class="fas fa-spinner"></i> Loading bridge data...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Other views -->
|
|
<div id="blocksView" class="detail-view">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">All Blocks</h2>
|
|
<button type="button" class="btn btn-primary" onclick="exportBlocksCSV()" style="padding: 0.5rem 1rem;"><i class="fas fa-file-csv"></i> Export CSV</button>
|
|
</div>
|
|
<div id="blocksList">
|
|
<div class="loading"><i class="fas fa-spinner"></i> Loading blocks...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="transactionsView" class="detail-view">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title">All Transactions</h2>
|
|
<button type="button" class="btn btn-primary" onclick="exportTransactionsListCSV()" style="padding: 0.5rem 1rem;"><i class="fas fa-file-csv"></i> Export CSV</button>
|
|
</div>
|
|
<div id="transactionsList">
|
|
<div class="loading"><i class="fas fa-spinner"></i> Loading transactions...</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="blockDetailView" class="detail-view">
|
|
<div class="breadcrumb" id="blockDetailBreadcrumb"></div>
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<button class="btn btn-secondary" onclick="showBlocks()" aria-label="Go back to blocks view"><i class="fas fa-arrow-left" aria-hidden="true"></i> Back</button>
|
|
<h2 class="card-title">Block Details</h2>
|
|
</div>
|
|
<div id="blockDetail"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="transactionDetailView" class="detail-view">
|
|
<div class="breadcrumb" id="transactionDetailBreadcrumb"></div>
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<button class="btn btn-secondary" onclick="showTransactions()" aria-label="Go back to transactions view"><i class="fas fa-arrow-left" aria-hidden="true"></i> Back</button>
|
|
<h2 class="card-title">Transaction Details</h2>
|
|
</div>
|
|
<div id="transactionDetail"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="addressDetailView" class="detail-view">
|
|
<div class="breadcrumb" id="addressDetailBreadcrumb"></div>
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<button class="btn btn-secondary" onclick="showHome()" aria-label="Go back to home page"><i class="fas fa-arrow-left" aria-hidden="true"></i> Back</button>
|
|
<h2 class="card-title">Address Details</h2>
|
|
</div>
|
|
<div id="addressDetail"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="tokenDetailView" class="detail-view">
|
|
<div class="breadcrumb" id="tokenDetailBreadcrumb"></div>
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<button class="btn btn-secondary" onclick="showHome()" aria-label="Go back"><i class="fas fa-arrow-left"></i> Back</button>
|
|
<h2 class="card-title">Token</h2>
|
|
</div>
|
|
<div id="tokenDetail"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="nftDetailView" class="detail-view">
|
|
<div class="breadcrumb" id="nftDetailBreadcrumb"></div>
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<button class="btn btn-secondary" onclick="showHome()" aria-label="Go back"><i class="fas fa-arrow-left"></i> Back</button>
|
|
<h2 class="card-title">NFT</h2>
|
|
</div>
|
|
<div id="nftDetail"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const API_BASE = '/api';
|
|
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;
|
|
(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;
|
|
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
|
|
// Origins that serve the explorer (FQDN or VM IP): use explicit same-origin API URL so nginx proxy is used
|
|
const EXPLORER_ORIGINS = ['https://explorer.d-bis.org', 'http://192.168.11.140', 'https://192.168.11.140'];
|
|
const BLOCKSCOUT_API = (typeof window !== 'undefined' && window.location && EXPLORER_ORIGINS.includes(window.location.origin)) ? (window.location.origin + '/api') : BLOCKSCOUT_API_ORIGIN;
|
|
let currentView = 'home';
|
|
let currentDetailKey = '';
|
|
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 (would fire hashchange and cause infinite recursion).
|
|
function switchToView(viewName) {
|
|
if (viewName !== 'blocks' && _blocksScrollAnimationId != null) {
|
|
cancelAnimationFrame(_blocksScrollAnimationId);
|
|
_blocksScrollAnimationId = null;
|
|
}
|
|
currentView = viewName;
|
|
var detailViews = ['blockDetail','transactionDetail','addressDetail','tokenDetail','nftDetail'];
|
|
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');
|
|
}
|
|
// Expose nav handlers: re-entrancy guard prevents hashchange from calling stub again while we're inside
|
|
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.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.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; } };
|
|
window.showBlockDetail = function() {};
|
|
window.showTransactionDetail = function() {};
|
|
window.showAddressDetail = function() {};
|
|
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('/api/v1/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('/api/v1/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('/api/v1/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) {
|
|
console.error('Wallet connection error:', error);
|
|
alert('Failed to connect wallet: ' + error.message);
|
|
}
|
|
}
|
|
|
|
// 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;
|
|
return {
|
|
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,
|
|
name: blockscoutAddr.name || null,
|
|
ens_domain_name: blockscoutAddr.ens_domain_name || 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>';
|
|
}
|
|
}
|
|
|
|
// 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();
|
|
if (window.location.hash) applyHashRoute();
|
|
window.addEventListener('hashchange', function() { if (window.location.hash) applyHashRoute(); });
|
|
window.addEventListener('load', function() { if (window.location.hash) applyHashRoute(); });
|
|
console.log('Loading stats, blocks, and transactions...');
|
|
loadStats();
|
|
loadLatestBlocks();
|
|
loadLatestTransactions();
|
|
startTransactionUpdates();
|
|
|
|
// 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 (error) {
|
|
console.error('Error checking MetaMask:', error);
|
|
}
|
|
}
|
|
} finally {
|
|
checkingMetaMask = false;
|
|
}
|
|
}
|
|
|
|
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) {
|
|
console.error('Error connecting MetaMask:', error);
|
|
let errorMessage = error.message || 'Unknown error';
|
|
if (errorMessage.includes('ethers is not defined') || typeof ethers === 'undefined') {
|
|
errorMessage = 'Ethers library failed to load. Please refresh the page.';
|
|
}
|
|
alert('Failed to connect MetaMask: ' + errorMessage);
|
|
}
|
|
} 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) {
|
|
console.error('Error adding chain:', 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 = ethers.utils.parseEther(amount);
|
|
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 weth9Contract = new ethers.Contract(WETH9_ADDRESS, WETH_ABI, signer);
|
|
const amountWei = ethers.utils.parseEther(amount);
|
|
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 = ethers.utils.parseEther(amount);
|
|
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 weth10Contract = new ethers.Contract(WETH10_ADDRESS, WETH_ABI, signer);
|
|
const amountWei = ethers.utils.parseEther(amount);
|
|
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 showWETHUtilities() {
|
|
showView('weth');
|
|
if ((window.location.hash || '').replace(/^#/, '').split('/')[0] !== 'weth') window.location.hash = '#/weth';
|
|
if (userAddress) {
|
|
await refreshWETHBalances();
|
|
}
|
|
}
|
|
window._showWETHUtilities = showWETHUtilities;
|
|
|
|
async function showBridgeMonitoring() {
|
|
showView('bridge');
|
|
if ((window.location.hash || '').replace(/^#/, '').split('/')[0] !== 'bridge') window.location.hash = '#/bridge';
|
|
await refreshBridgeData();
|
|
}
|
|
window._showBridgeMonitoring = showBridgeMonitoring;
|
|
|
|
async function showHome() {
|
|
showView('home');
|
|
if ((window.location.hash || '').replace(/^#/, '') !== 'home') window.location.hash = '#/home';
|
|
await loadStats();
|
|
await loadLatestBlocks();
|
|
await loadLatestTransactions();
|
|
// Start real-time transaction updates
|
|
startTransactionUpdates();
|
|
}
|
|
window._showHome = showHome;
|
|
|
|
async function showBlocks() {
|
|
showView('blocks');
|
|
if ((window.location.hash || '').replace(/^#/, '').split('/')[0] !== 'blocks') window.location.hash = '#/blocks';
|
|
await loadAllBlocks();
|
|
}
|
|
window._showBlocks = showBlocks;
|
|
|
|
async function showTransactions() {
|
|
showView('transactions');
|
|
if ((window.location.hash || '').replace(/^#/, '').split('/')[0] !== 'transactions') window.location.hash = '#/transactions';
|
|
await loadAllTransactions();
|
|
}
|
|
window._showTransactions = showTransactions;
|
|
|
|
// Analytics view (Track 3+)
|
|
function showAnalytics() {
|
|
if (!hasAccess(3)) {
|
|
showToast('Analytics features require Track 3 access. Please connect your wallet and ensure you are approved.', 'error');
|
|
return;
|
|
}
|
|
showView('analytics');
|
|
// TODO: Load analytics dashboard
|
|
document.getElementById('mainContent').innerHTML = `
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title"><i class="fas fa-chart-line"></i> Analytics Dashboard</h2>
|
|
</div>
|
|
<div class="card-body">
|
|
<p>Analytics dashboard coming soon. Track 3 features enabled.</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
window._showAnalytics = showAnalytics;
|
|
|
|
// Operator view (Track 4)
|
|
function showOperator() {
|
|
if (!hasAccess(4)) {
|
|
showToast('Operator features require Track 4 access. Please connect your wallet and ensure you are approved.', 'error');
|
|
return;
|
|
}
|
|
showView('operator');
|
|
// TODO: Load operator panel
|
|
document.getElementById('mainContent').innerHTML = `
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h2 class="card-title"><i class="fas fa-cog"></i> Operator Panel</h2>
|
|
</div>
|
|
<div class="card-body">
|
|
<p>Operator panel coming soon. Track 4 features enabled.</p>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
window._showOperator = showOperator;
|
|
|
|
function showView(viewName) {
|
|
currentView = viewName;
|
|
var detailViews = ['blockDetail','transactionDetail','addressDetail','tokenDetail','nftDetail'];
|
|
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 applyHashRoute() {
|
|
var hash = (window.location.hash || '').replace(/^#/, '');
|
|
if (!hash) return;
|
|
var parts = hash.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]); if (currentDetailKey === 'block:' + p1) return; showBlockDetail(p1); return; }
|
|
if (parts[0] === 'tx' && parts[1]) { var p1 = decode(parts[1]); if (currentDetailKey === 'tx:' + p1) return; showTransactionDetail(p1); return; }
|
|
if (parts[0] === 'address' && parts[1]) { var p1 = decode(parts[1]); if (currentDetailKey === 'address:' + p1.toLowerCase()) return; showAddressDetail(p1); return; }
|
|
if (parts[0] === 'token' && parts[1]) { var p1 = decode(parts[1]); if (currentDetailKey === 'token:' + p1.toLowerCase()) return; showTokenDetail(p1); return; }
|
|
if (parts[0] === 'nft' && parts[1] && parts[2]) { var p1 = decode(parts[1]), p2 = decode(parts[2]); if (currentDetailKey === 'nft:' + p1.toLowerCase() + ':' + p2) return; showNftDetail(p1, p2); 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] === 'bridge') { if (currentView !== 'bridge') showBridgeMonitoring(); return; }
|
|
if (parts[0] === 'weth') { if (currentView !== 'weth') showWETHUtilities(); 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;
|
|
if (document.readyState !== 'loading' && window.location.hash) { applyHashRoute(); }
|
|
window.toggleDarkMode = toggleDarkMode;
|
|
|
|
function focusSearchWithHint(kind) {
|
|
showHome();
|
|
var input = document.getElementById('searchInput');
|
|
if (input) {
|
|
input.focus();
|
|
if (kind === 'token') showToast('Enter token contract address (0x...) or search by name/symbol', 'info', 4000);
|
|
}
|
|
}
|
|
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';
|
|
}
|
|
window.toggleNavMenu = toggleNavMenu;
|
|
window.closeNavMenu = closeNavMenu;
|
|
|
|
// Update breadcrumb navigation
|
|
function updateBreadcrumb(type, identifier, identifierExtra) {
|
|
let breadcrumbContainer;
|
|
let breadcrumbHTML = '<a href="#/home">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 #${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><span>Address</span><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 '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;
|
|
}
|
|
}
|
|
function updateBreadcrumb(type, identifier, identifierExtra) {
|
|
let breadcrumbContainer;
|
|
let breadcrumbHTML = '<a href="#/home">Home</a>';
|
|
switch (type) {
|
|
case 'block':
|
|
breadcrumbContainer = document.getElementById('blockDetailBreadcrumb');
|
|
breadcrumbHTML += '<span class="breadcrumb-separator">/</span><a href="#/blocks">Blocks</a><span class="breadcrumb-separator">/</span><span class="breadcrumb-current">Block #' + identifier + '</span>';
|
|
break;
|
|
case 'transaction':
|
|
breadcrumbContainer = document.getElementById('transactionDetailBreadcrumb');
|
|
breadcrumbHTML += '<span class="breadcrumb-separator">/</span><a href="#/transactions">Transactions</a><span class="breadcrumb-separator">/</span><span class="breadcrumb-current">' + escapeHtml(shortenHash(identifier)) + '</span>';
|
|
break;
|
|
case 'address':
|
|
breadcrumbContainer = document.getElementById('addressDetailBreadcrumb');
|
|
breadcrumbHTML += '<span class="breadcrumb-separator">/</span><span class="breadcrumb-separator">Address</span><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><a href="#/tokens">Tokens</a><span class="breadcrumb-separator">/</span><span class="breadcrumb-current">Token ' + escapeHtml(shortenHash(identifier)) + '</span>';
|
|
break;
|
|
case 'nft':
|
|
breadcrumbContainer = document.getElementById('nftDetailBreadcrumb');
|
|
breadcrumbHTML += '<span class="breadcrumb-separator">/</span><a href="#/address/' + encodeURIComponent(identifier) + '">' + escapeHtml(shortenHash(identifier)) + '</a><span class="breadcrumb-separator">/</span><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);
|
|
|
|
// 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 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`);
|
|
}
|
|
|
|
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">2 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">2 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');
|
|
if (!el) return;
|
|
try {
|
|
var blocksResp = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/blocks?page=1&page_size=5');
|
|
var blocks = blocksResp.items || blocksResp || [];
|
|
var statsResp = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/stats').catch(function() { return {}; });
|
|
var totalTxs = statsResp.total_transactions || 0;
|
|
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);
|
|
el.innerHTML = (gasGwei !== '-' ? 'Gas: ' + escapeHtml(gasGwei) + '<br/>' : '') + (blockTimeSec !== '-' ? 'Block: ' + escapeHtml(blockTimeSec) + '<br/>' : '') + (tps !== '-' ? 'TPS: ' + escapeHtml(tps) : '') || 'Gas / TPS';
|
|
} catch (e) {
|
|
el.textContent = '-';
|
|
}
|
|
}
|
|
|
|
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);
|
|
|
|
if (limitedBlocks.length === 0) {
|
|
if (container) container.innerHTML = '<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 {
|
|
// Create HTML with duplicated blocks for seamless infinite loop
|
|
let html = '<div class="blocks-scroll-container" id="blocksScrollContainer">';
|
|
html += '<div class="blocks-scroll-content">';
|
|
|
|
// First set of blocks (with animations for first 3)
|
|
limitedBlocks.forEach(function(block, index) {
|
|
var animationClass = index < 3 ? 'new-block' : '';
|
|
html += createBlockCardHtml(block, { animationClass: animationClass });
|
|
});
|
|
|
|
// Duplicate blocks for seamless infinite loop
|
|
limitedBlocks.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 = limitedBlocks.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?filter=to&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);
|
|
|
|
// Check for new transactions
|
|
const currentHashes = new Set(limitedTransactions.map(tx => String(tx.hash || '')));
|
|
const newTransactions = limitedTransactions.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 = '<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 {
|
|
limitedTransactions.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' : '';
|
|
|
|
html += '<tr class="' + animationClass + '" onclick="showTransactionDetail(\'' + escapeHtml(hash) + '\')" style="cursor: pointer;"><td class="hash">' + escapeHtml(shortenHash(hash)) + '</td><td class="hash">' + escapeHtml(shortenHash(from)) + '</td><td class="hash">' + escapeHtml(shortenHash(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;
|
|
}
|
|
}
|
|
|
|
async function loadAllBlocks() {
|
|
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 = [];
|
|
|
|
// For ChainID 138, use Blockscout API
|
|
if (CHAIN_ID === 138) {
|
|
try {
|
|
// Fetch blocks from Blockscout API (paginated)
|
|
const response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/blocks?page=1&page_size=50`);
|
|
if (response && response.items) {
|
|
blocks = response.items.map(normalizeBlock).filter(b => b !== null);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load blocks from Blockscout:', error);
|
|
throw error;
|
|
}
|
|
} 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
|
|
}
|
|
}
|
|
}
|
|
|
|
let html = '<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 {
|
|
blocks.forEach(function(block) {
|
|
var d = normalizeBlockDisplay(block);
|
|
html += '<tr onclick="showBlockDetail(\'' + escapeHtml(String(d.blockNum)) + '\')" style="cursor: pointer;"><td>' + escapeHtml(String(d.blockNum)) + '</td><td class="hash">' + escapeHtml(shortenHash(d.hash)) + '</td><td>' + escapeHtml(String(d.txCount)) + '</td><td>' + escapeHtml(d.timestampFormatted) + '</td></tr>';
|
|
});
|
|
}
|
|
|
|
html += '</tbody></table>';
|
|
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() {
|
|
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 = [];
|
|
|
|
// For ChainID 138, use Blockscout API
|
|
if (CHAIN_ID === 138) {
|
|
try {
|
|
// Fetch transactions from Blockscout API (paginated)
|
|
const response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/transactions?page=1&page_size=50`);
|
|
if (response && response.items) {
|
|
transactions = response.items.map(normalizeTransaction).filter(tx => tx !== null);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load transactions from Blockscout:', error);
|
|
throw error;
|
|
}
|
|
} 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
|
|
}
|
|
}
|
|
}
|
|
|
|
let html = '<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 {
|
|
transactions.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);
|
|
html += '<tr onclick="showTransactionDetail(\'' + escapeHtml(hash) + '\')" style="cursor: pointer;"><td class="hash">' + escapeHtml(shortenHash(hash)) + '</td><td class="hash">' + escapeHtml(shortenHash(from)) + '</td><td class="hash">' + escapeHtml(shortenHash(to)) + '</td><td>' + escapeHtml(valueFormatted) + ' ETH</td><td>' + escapeHtml(String(blockNumber)) + '</td></tr>';
|
|
});
|
|
}
|
|
|
|
html += '</tbody></table>';
|
|
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 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 = '0x89dd12025bfCD38A168455A44B400e913ED33BE2';
|
|
const WETH10_BRIDGE_138 = '0xe0E93247376aa097dB308B92e6Ba36bA015535D0';
|
|
|
|
// Ethereum Mainnet Bridge Contracts
|
|
const WETH9_BRIDGE_MAINNET = '0x2A0840e5117683b11682ac46f5CF5621E67269E3';
|
|
const WETH10_BRIDGE_MAINNET = '0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03';
|
|
|
|
// 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
|
|
}
|
|
};
|
|
|
|
// Build HTML
|
|
let html = `
|
|
<div class="bridge-chain-card">
|
|
<div class="chain-name"><i class="fas fa-network-wired"></i> CCIP Bridge Ecosystem</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>
|
|
</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="showAddressDetail('${WETH9_BRIDGE_138}')" style="cursor: pointer; font-size: 0.9rem;">${WETH9_BRIDGE_138}</span>
|
|
</div>
|
|
<div style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--text-light);">
|
|
Token: <span class="hash" onclick="showAddressDetail('0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2')" style="cursor: pointer;">WETH9</span>
|
|
</div>
|
|
</div>
|
|
<div class="chain-stat" style="background: var(--light); padding: 1rem; border-radius: 8px;">
|
|
<div class="chain-stat-label" style="font-weight: 600; margin-bottom: 0.5rem;">CCIPWETH10Bridge</div>
|
|
<div class="chain-stat-value">
|
|
<span class="hash" onclick="showAddressDetail('${WETH10_BRIDGE_138}')" style="cursor: pointer; font-size: 0.9rem;">${WETH10_BRIDGE_138}</span>
|
|
</div>
|
|
<div style="margin-top: 0.5rem; font-size: 0.85rem; color: var(--text-light);">
|
|
Token: <span class="hash" onclick="showAddressDetail('0xf4BB2e28688e89fCcE3c0580D37d36A7672E8A9f')" style="cursor: pointer;">WETH10</span>
|
|
</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">7 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>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
`;
|
|
|
|
// Add WETH9 routes
|
|
for (const [chain, address] of Object.entries(routes.weth9)) {
|
|
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>
|
|
</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">7 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>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
`;
|
|
|
|
// Add WETH10 routes
|
|
for (const [chain, address] of Object.entries(routes.weth10)) {
|
|
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>
|
|
</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>
|
|
</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')" 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" 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')" 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" style="color: var(--primary);">View on Etherscan</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Bridge Information -->
|
|
<div class="card">
|
|
<h3><i class="fas fa-info-circle"></i> Bridge Information</h3>
|
|
<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>
|
|
</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>: <span class="hash" onclick="showAddressDetail('0x8078A09637e47Fa5Ed34F626046Ea2094a5CDE5e')" style="cursor: pointer;">0x8078A09637e47Fa5Ed34F626046Ea2094a5CDE5e</span></li>
|
|
<li><strong>CCIP Sender (Chain 138)</strong>: <span class="hash" onclick="showAddressDetail('0x105F8A15b819948a89153505762444Ee9f324684')" style="cursor: pointer;">0x105F8A15b819948a89153505762444Ee9f324684</span></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); return /^0x[a-fA-F0-9]{40}$/i.test(s) ? s : null; }
|
|
async function showBlockDetail(blockNumber) {
|
|
const bn = safeBlockNumber(blockNumber);
|
|
if (!bn) { showToast('Invalid block number', 'error'); return; }
|
|
blockNumber = bn;
|
|
currentDetailKey = 'block:' + blockNumber;
|
|
showView('blockDetail');
|
|
window.location.hash = '#/block/' + blockNumber;
|
|
try { window.history.replaceState(null, '', '#' + '/block/' + blockNumber); } catch (e) {}
|
|
const container = document.getElementById('blockDetail');
|
|
updateBreadcrumb('block', blockNumber);
|
|
container.innerHTML = createSkeletonLoader('detail');
|
|
|
|
try {
|
|
let b;
|
|
|
|
// For ChainID 138, use Blockscout API directly
|
|
if (CHAIN_ID === 138) {
|
|
try {
|
|
const response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/blocks/${blockNumber}`);
|
|
b = normalizeBlock(response);
|
|
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 || '')}</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">Parent Hash</div>
|
|
<div class="info-value hash" onclick="showBlockDetail('${escapeHtml(String(parseInt(b.number) - 1))}')" style="cursor: pointer;">${escapeHtml(b.parent_hash || '')}</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 hash" onclick="showAddressDetail('${escapeHtml(b.miner || '')}')" style="cursor: pointer;">${escapeHtml(b.miner || 'N/A')}</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>
|
|
`;
|
|
} 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 = showBlockDetail;
|
|
|
|
async function showTransactionDetail(txHash) {
|
|
const th = safeTxHash(txHash);
|
|
if (!th) { showToast('Invalid transaction hash', 'error'); return; }
|
|
txHash = th;
|
|
currentDetailKey = 'tx:' + txHash;
|
|
showView('transactionDetail');
|
|
window.location.hash = '#/tx/' + txHash;
|
|
try { window.history.replaceState(null, '', '#' + '/tx/' + txHash); } catch (e) {}
|
|
const container = document.getElementById('transactionDetail');
|
|
updateBreadcrumb('transaction', txHash);
|
|
container.innerHTML = createSkeletonLoader('detail');
|
|
|
|
try {
|
|
let t;
|
|
let rawTx = null;
|
|
|
|
if (CHAIN_ID === 138) {
|
|
try {
|
|
const response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/transactions/${txHash}`);
|
|
rawTx = response;
|
|
t = normalizeTransaction(response);
|
|
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);
|
|
|
|
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">${t.hash}</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 hash" onclick="showBlockDetail('${escapeHtml(String(t.block_number || ''))}')" style="cursor: pointer;">${escapeHtml(String(t.block_number || 'N/A'))}</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 hash" onclick="showAddressDetail('${escapeHtml(t.from || '')}')" style="cursor: pointer;">${escapeHtml(t.from || '')}</div>
|
|
</div>
|
|
<div class="info-row">
|
|
<div class="info-label">To</div>
|
|
<div class="info-value hash" onclick="showAddressDetail('${escapeHtml(t.to || 'N/A')}')" style="cursor: pointer;">${escapeHtml(t.to || 'N/A')}</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 hash" onclick="showAddressDetail('${escapeHtml(t.contract_address)}')" style="cursor: pointer;">${escapeHtml(t.contract_address)}</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) {
|
|
if (internals.length === 0) {
|
|
internalEl.innerHTML = '<p style="color: var(--text-light);">No internal transactions</p>';
|
|
} else {
|
|
let tbl = '<table class="table"><thead><tr><th>Type</th><th>From</th><th>To</th><th>Value</th></tr></thead><tbody>';
|
|
internals.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 class="hash" onclick="showAddressDetail(\'' + escapeHtml(from) + '\')" style="cursor: pointer;">' + escapeHtml(shortenHash(from)) + '</td><td class="hash" onclick="showAddressDetail(\'' + escapeHtml(to) + '\')" style="cursor: pointer;">' + escapeHtml(shortenHash(to)) + '</td><td>' + escapeHtml(val) + ' ETH</td></tr>';
|
|
});
|
|
tbl += '</tbody></table>';
|
|
internalEl.innerHTML = tbl;
|
|
}
|
|
}
|
|
|
|
const logsEl = document.getElementById('txLogs');
|
|
if (logsEl) {
|
|
if (logs.length === 0) {
|
|
logsEl.innerHTML = '<p style="color: var(--text-light);">No event logs</p>';
|
|
} else {
|
|
let tbl = '<table class="table"><thead><tr><th>Address</th><th>Topics</th><th>Data</th></tr></thead><tbody>';
|
|
logs.forEach(function(log) {
|
|
const addr = log.address?.hash || log.address || 'N/A';
|
|
const topics = (log.topics && Array.isArray(log.topics)) ? log.topics.join(', ') : (log.topic0 || log.topics || '-');
|
|
const data = log.data || log.raw_data || '0x';
|
|
tbl += '<tr><td class="hash" onclick="showAddressDetail(\'' + escapeHtml(addr) + '\')" style="cursor: pointer;">' + escapeHtml(shortenHash(addr)) + '</td><td style="word-break: break-all; font-size: 0.75rem;">' + escapeHtml(String(topics).substring(0, 80)) + (String(topics).length > 80 ? '...' : '') + '</td><td style="word-break: break-all; font-size: 0.75rem;">' + escapeHtml(String(data).substring(0, 66)) + (String(data).length > 66 ? '...' : '') + '</td></tr>';
|
|
});
|
|
tbl += '</tbody></table>';
|
|
logsEl.innerHTML = tbl;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
} 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() {
|
|
fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/blocks?page=1&page_size=50').then(function(r) {
|
|
var items = r.items || r || [];
|
|
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() {
|
|
fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/transactions?page=1&page_size=50').then(function(r) {
|
|
var items = r.items || r || [];
|
|
var rows = [['Hash', 'From', 'To', 'Value', 'Block']];
|
|
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 || '')]);
|
|
});
|
|
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'); });
|
|
}
|
|
window.showTransactionDetail = showTransactionDetail;
|
|
|
|
async function showAddressDetail(address) {
|
|
const addr = safeAddress(address);
|
|
if (!addr) { showToast('Invalid address', 'error'); return; }
|
|
address = addr;
|
|
currentDetailKey = 'address:' + address.toLowerCase();
|
|
showView('addressDetail');
|
|
window.location.hash = '#/address/' + address;
|
|
try { window.history.replaceState(null, '', '#' + '/address/' + address); } catch (e) {}
|
|
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) {
|
|
container.innerHTML = '<div class="error">Failed to load address: ' + escapeHtml(error.message || 'Unknown error') + '. <button onclick="showAddressDetail(\'' + address + '\')" 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 contractLink = isContract ? `<a href="https://explorer.d-bis.org/address/${address}/contract" target="_blank" rel="noopener" style="color: var(--primary); font-size: 0.875rem;">View contract on Blockscout</a>` : '';
|
|
|
|
container.innerHTML = `
|
|
<div class="info-row">
|
|
<div class="info-label">Address</div>
|
|
<div class="info-value hash">${address}</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>
|
|
<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>
|
|
${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;">
|
|
<h3>Recent Transactions</h3>
|
|
<div id="addressTransactions" class="loading">Loading transactions...</div>
|
|
</div>
|
|
<div id="addressTabTokens" class="address-tab-content card" style="margin-top: 1rem; display: none;">
|
|
<h3>Token Balances</h3>
|
|
<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>
|
|
${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 === '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';
|
|
if (!items || items.length === 0) {
|
|
el.innerHTML = '<p style="color: var(--text-light);">No token balances</p>';
|
|
return;
|
|
}
|
|
let tbl = '<table class="table"><thead><tr><th>Token</th><th>Contract</th><th>Balance</th><th>Type</th></tr></thead><tbody>';
|
|
items.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 class="hash" onclick="showAddressDetail(\'' + escapeHtml(contract) + '\')" style="cursor: pointer;">' + escapeHtml(shortenHash(contract)) + '</td><td>' + escapeHtml(displayBalance) + '</td><td>' + escapeHtml(type) + '</td></tr>';
|
|
});
|
|
tbl += '</tbody></table>';
|
|
el.innerHTML = tbl;
|
|
} catch (e) {
|
|
el.innerHTML = '<p class="error">Failed to load token balances</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';
|
|
if (!items || items.length === 0) {
|
|
el.innerHTML = '<p style="color: var(--text-light);">No internal transactions</p>';
|
|
return;
|
|
}
|
|
let tbl = '<table class="table"><thead><tr><th>Block</th><th>From</th><th>To</th><th>Value</th><th>Tx Hash</th></tr></thead><tbody>';
|
|
items.slice(0, 25).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 onclick="showBlockDetail(\'' + escapeHtml(block) + '\')" style="cursor: pointer;">' + escapeHtml(block) + '</td><td class="hash" onclick="showAddressDetail(\'' + escapeHtml(from) + '\')" style="cursor: pointer;">' + escapeHtml(shortenHash(from)) + '</td><td class="hash" onclick="showAddressDetail(\'' + escapeHtml(to) + '\')" style="cursor: pointer;">' + escapeHtml(shortenHash(to)) + '</td><td>' + escapeHtml(val) + ' ETH</td><td class="hash" onclick="showTransactionDetail(\'' + escapeHtml(txHash) + '\')" style="cursor: pointer;">' + (txHash !== '-' ? escapeHtml(shortenHash(txHash)) : '-') + '</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="https://explorer.d-bis.org/address/' + encodeURIComponent(addr) + '/contract" target="_blank" rel="noopener">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>';
|
|
html += '<p style="margin-top: 0.5rem;"><a href="https://explorer.d-bis.org/address/' + encodeURIComponent(addr) + '/contract" target="_blank" rel="noopener" style="color: var(--primary);">Read / Write contract on Blockscout</a></p>';
|
|
el.innerHTML = html;
|
|
} 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) {
|
|
if (txs.data && txs.data.length > 0) {
|
|
let txHtml = '<table class="table"><thead><tr><th>Hash</th><th>Block</th><th>From</th><th>To</th><th>Value</th></tr></thead><tbody>';
|
|
txs.data.forEach(function(tx) {
|
|
txHtml += '<tr onclick="showTransactionDetail(\'' + escapeHtml(tx.hash) + '\')" style="cursor: pointer;"><td class="hash">' + escapeHtml(shortenHash(tx.hash)) + '</td><td>' + escapeHtml(String(tx.block_number)) + '</td><td class="hash">' + escapeHtml(shortenHash(tx.from)) + '</td><td class="hash">' + escapeHtml(shortenHash(tx.to || 'N/A')) + '</td><td>' + escapeHtml(formatEther(tx.value || '0')) + ' ETH</td></tr>';
|
|
});
|
|
txHtml += '</tbody></table>';
|
|
txContainer.innerHTML = txHtml;
|
|
} else {
|
|
txContainer.innerHTML = '<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 = showAddressDetail;
|
|
|
|
async function showTokenDetail(tokenAddress) {
|
|
if (!/^0x[a-fA-F0-9]{40}$/.test(tokenAddress)) return;
|
|
currentDetailKey = 'token:' + tokenAddress.toLowerCase();
|
|
showView('tokenDetail');
|
|
window.location.hash = '#/token/' + tokenAddress;
|
|
try { window.history.replaceState(null, '', '#' + '/token/' + tokenAddress); } catch (e) {}
|
|
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 name = data.name || '-';
|
|
var symbol = data.symbol || '-';
|
|
var decimals = data.decimals != null ? data.decimals : 18;
|
|
var supply = data.total_supply != null ? data.total_supply : (data.total_supply_raw || '0');
|
|
var supplyNum = Number(supply) / Math.pow(10, parseInt(decimals, 10));
|
|
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 html = '<div class="info-row"><div class="info-label">Contract</div><div class="info-value hash" onclick="showAddressDetail(\'' + escapeHtml(tokenAddress) + '\')" style="cursor: pointer;">' + escapeHtml(tokenAddress) + '</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>';
|
|
html += '<div class="card" style="margin-top: 1rem;"><h3>Recent Transfers</h3>';
|
|
if (transfers.length === 0) {
|
|
html += '<p style="color: var(--text-light);">No transfers</p>';
|
|
} else {
|
|
html += '<table class="table"><thead><tr><th>From</th><th>To</th><th>Value</th><th>Tx</th></tr></thead><tbody>';
|
|
transfers.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 class="hash" onclick="showAddressDetail(\'' + escapeHtml(from) + '\')" style="cursor: pointer;">' + escapeHtml(shortenHash(from)) + '</td><td class="hash" onclick="showAddressDetail(\'' + escapeHtml(to) + '\')" style="cursor: pointer;">' + escapeHtml(shortenHash(to)) + '</td><td>' + escapeHtml(v.toLocaleString(undefined, { maximumFractionDigits: 6 })) + '</td><td class="hash" onclick="showTransactionDetail(\'' + escapeHtml(txHash) + '\')" style="cursor: pointer;">' + (txHash ? escapeHtml(shortenHash(txHash)) : '-') + '</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');
|
|
try { window.history.replaceState(null, '', '#' + '/nft/' + contractAddress + '/' + tokenId); } catch (e) {}
|
|
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 hash" onclick="showAddressDetail(\'' + escapeHtml(contractAddress) + '\')" style="cursor: pointer;">' + escapeHtml(contractAddress) + '</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 hash" onclick="showAddressDetail(\'' + escapeHtml(ownerAddr) + '\')" style="cursor: pointer;">' + escapeHtml(ownerAddr) + '</div></div>'; }
|
|
}
|
|
html += '<p style="margin-top: 1rem;"><a href="https://explorer.d-bis.org/token/' + encodeURIComponent(contractAddress) + '/instance/' + encodeURIComponent(tokenId) + '" target="_blank" rel="noopener" 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;
|
|
|
|
async function handleSearch(query) {
|
|
query = query.trim();
|
|
if (!query) {
|
|
showToast('Please enter a search query', 'info');
|
|
return;
|
|
}
|
|
|
|
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) {
|
|
var item = searchResults.items[0];
|
|
var type = item.type || item.address_type || '';
|
|
if (item.address_hash || item.hash) {
|
|
var addr = item.address_hash || item.hash;
|
|
await showAddressDetail(addr);
|
|
return;
|
|
}
|
|
if (item.tx_hash || item.hash) {
|
|
var txHash = item.tx_hash || item.hash;
|
|
if (txHash.length === 66) {
|
|
await showTransactionDetail(txHash);
|
|
return;
|
|
}
|
|
}
|
|
if (item.block_number != null) {
|
|
await showBlockDetail(String(item.block_number));
|
|
return;
|
|
}
|
|
}
|
|
if (/^0x[a-f0-9]{8,63}$/i.test(normalizedQuery)) {
|
|
try {
|
|
var txResp = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/transactions/' + normalizedQuery);
|
|
if (txResp && (txResp.hash || txResp.tx_hash)) {
|
|
await showTransactionDetail(txResp.hash || txResp.tx_hash);
|
|
return;
|
|
}
|
|
} catch (e) {}
|
|
showToast('No unique result for partial hash. Enter full transaction hash (0x + 64 hex).', 'info');
|
|
return;
|
|
}
|
|
}
|
|
|
|
showToast('Invalid search. Try address (0x...40 hex), transaction hash (0x...64 hex), block number, or search by 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+$/, '');
|
|
}
|
|
|
|
// 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 input and button handlers; mobile nav close on link click
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
const searchInput = document.getElementById('searchInput');
|
|
const searchBtn = document.getElementById('searchBtn');
|
|
if (searchInput) {
|
|
searchInput.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
handleSearch(e.target.value);
|
|
}
|
|
});
|
|
}
|
|
if (searchBtn) {
|
|
searchBtn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
if (searchInput) handleSearch(searchInput.value);
|
|
});
|
|
}
|
|
var navLinks = document.getElementById('navLinks');
|
|
if (navLinks) {
|
|
navLinks.addEventListener('click', function(e) {
|
|
if (e.target.closest('a')) closeNavMenu();
|
|
});
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|