From a53c15507ffabaf4280b85260cc4b9830824b71c Mon Sep 17 00:00:00 2001 From: defiQUG Date: Mon, 16 Feb 2026 03:09:53 -0800 Subject: [PATCH] fix: API JSON error responses + navbar with dropdowns - Add backend/libs/go-http-errors for consistent JSON errors - REST API: use writeMethodNotAllowed, writeNotFound, writeInternalError - middleware, gateway, search: use httperrors.WriteJSON - SPA: navbar with Explore/Tools/More dropdowns, initNavDropdowns() - Next.js: Navbar component with dropdowns + mobile menu Co-authored-by: Cursor --- backend/api/gateway/gateway.go | 6 +- backend/api/middleware/security.go | 4 +- backend/api/rest/addresses.go | 8 +- backend/api/rest/config.go | 4 +- backend/api/rest/errors.go | 5 + backend/api/rest/etherscan.go | 5 +- backend/api/rest/search.go | 5 +- backend/api/rest/server.go | 46 +- backend/api/rest/stats.go | 5 +- backend/api/rest/transactions.go | 9 +- backend/api/search/search.go | 7 +- backend/libs/go-http-errors/errors.go | 26 + frontend/public/explorer-spa.js | 3532 +++++++++++++++++++++ frontend/public/index.html | 3311 ++----------------- frontend/src/app/layout.tsx | 31 +- frontend/src/components/common/Navbar.tsx | 166 + 16 files changed, 3979 insertions(+), 3191 deletions(-) create mode 100644 backend/libs/go-http-errors/errors.go create mode 100644 frontend/public/explorer-spa.js create mode 100644 frontend/src/components/common/Navbar.tsx diff --git a/backend/api/gateway/gateway.go b/backend/api/gateway/gateway.go index eb5ba25..a8fa25c 100644 --- a/backend/api/gateway/gateway.go +++ b/backend/api/gateway/gateway.go @@ -6,6 +6,8 @@ import ( "net/http" "net/http/httputil" "net/url" + + httperrors "github.com/explorer/backend/libs/go-http-errors" ) // Gateway represents the API gateway @@ -51,13 +53,13 @@ func (g *Gateway) handleRequest(proxy *httputil.ReverseProxy) http.HandlerFunc { // Authentication if !g.auth.Authenticate(r) { - http.Error(w, "Unauthorized", http.StatusUnauthorized) + httperrors.WriteJSON(w, http.StatusUnauthorized, "UNAUTHORIZED", "Unauthorized") return } // Rate limiting if !g.rateLimiter.Allow(r) { - http.Error(w, "Rate limit exceeded", http.StatusTooManyRequests) + httperrors.WriteJSON(w, http.StatusTooManyRequests, "RATE_LIMIT_EXCEEDED", "Rate limit exceeded") return } diff --git a/backend/api/middleware/security.go b/backend/api/middleware/security.go index 0e57b7b..921bf36 100644 --- a/backend/api/middleware/security.go +++ b/backend/api/middleware/security.go @@ -3,6 +3,8 @@ package middleware import ( "net/http" "strings" + + httperrors "github.com/explorer/backend/libs/go-http-errors" ) // SecurityMiddleware adds security headers @@ -52,7 +54,7 @@ func (m *SecurityMiddleware) BlockWriteCalls(next http.Handler) http.Handler { if !strings.Contains(path, "weth") && !strings.Contains(path, "wrap") && !strings.Contains(path, "unwrap") { // Block other write operations for Track 1 if strings.HasPrefix(path, "/api/v1/track1") { - http.Error(w, "Write operations not allowed for Track 1 (public)", http.StatusForbidden) + httperrors.WriteJSON(w, http.StatusForbidden, "FORBIDDEN", "Write operations not allowed for Track 1 (public)") return } } diff --git a/backend/api/rest/addresses.go b/backend/api/rest/addresses.go index 0b918c0..c03d589 100644 --- a/backend/api/rest/addresses.go +++ b/backend/api/rest/addresses.go @@ -12,20 +12,20 @@ import ( // handleGetAddress handles GET /api/v1/addresses/{chain_id}/{address} func (s *Server) handleGetAddress(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + writeMethodNotAllowed(w) return } // Parse address from URL address := r.URL.Query().Get("address") if address == "" { - http.Error(w, "Address required", http.StatusBadRequest) + writeValidationError(w, fmt.Errorf("address required")) return } // Validate address format if !isValidAddress(address) { - http.Error(w, "Invalid address format", http.StatusBadRequest) + writeValidationError(w, ErrInvalidAddress) return } @@ -40,7 +40,7 @@ func (s *Server) handleGetAddress(w http.ResponseWriter, r *http.Request) { s.chainID, address, ).Scan(&txCount) if err != nil { - http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError) + writeInternalError(w, "Database error") return } diff --git a/backend/api/rest/config.go b/backend/api/rest/config.go index 57fab5b..220151c 100644 --- a/backend/api/rest/config.go +++ b/backend/api/rest/config.go @@ -15,7 +15,7 @@ var dualChainTokenListJSON []byte func (s *Server) handleConfigNetworks(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.Header().Set("Allow", "GET") - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + writeMethodNotAllowed(w) return } w.Header().Set("Content-Type", "application/json") @@ -27,7 +27,7 @@ func (s *Server) handleConfigNetworks(w http.ResponseWriter, r *http.Request) { func (s *Server) handleConfigTokenList(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { w.Header().Set("Allow", "GET") - http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + writeMethodNotAllowed(w) return } w.Header().Set("Content-Type", "application/json") diff --git a/backend/api/rest/errors.go b/backend/api/rest/errors.go index d7cc7f5..2fd541a 100644 --- a/backend/api/rest/errors.go +++ b/backend/api/rest/errors.go @@ -49,3 +49,8 @@ func writeForbidden(w http.ResponseWriter) { writeError(w, http.StatusForbidden, "FORBIDDEN", "Access denied") } +// writeMethodNotAllowed writes a 405 error response (JSON) +func writeMethodNotAllowed(w http.ResponseWriter) { + writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "Method not allowed") +} + diff --git a/backend/api/rest/etherscan.go b/backend/api/rest/etherscan.go index 28f5b92..021a9f4 100644 --- a/backend/api/rest/etherscan.go +++ b/backend/api/rest/etherscan.go @@ -13,7 +13,10 @@ import ( // This provides Etherscan-compatible API endpoints func (s *Server) handleEtherscanAPI(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + writeMethodNotAllowed(w) + return + } + if !s.requireDB(w) { return } diff --git a/backend/api/rest/search.go b/backend/api/rest/search.go index bd63601..53b3e30 100644 --- a/backend/api/rest/search.go +++ b/backend/api/rest/search.go @@ -8,7 +8,10 @@ import ( // handleSearch handles GET /api/v1/search func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + writeMethodNotAllowed(w) + return + } + if !s.requireDB(w) { return } diff --git a/backend/api/rest/server.go b/backend/api/rest/server.go index b0c4027..351b39a 100644 --- a/backend/api/rest/server.go +++ b/backend/api/rest/server.go @@ -13,6 +13,7 @@ import ( "github.com/explorer/backend/auth" "github.com/explorer/backend/api/middleware" + httpmiddleware "github.com/explorer/backend/libs/go-http-middleware" "github.com/jackc/pgx/v5/pgxpool" ) @@ -54,8 +55,12 @@ func (s *Server) Start(port int) error { // Setup track routes with proper middleware s.SetupTrackRoutes(mux, authMiddleware) - // Initialize security middleware - securityMiddleware := middleware.NewSecurityMiddleware() + // Security headers (reusable lib; CSP from env or explorer default) + csp := os.Getenv("CSP_HEADER") + if csp == "" { + csp = "default-src 'self'; 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;" + } + securityMiddleware := httpmiddleware.NewSecurity(csp) // Add middleware for all routes (outermost to innermost) handler := securityMiddleware.AddSecurityHeaders( @@ -82,9 +87,13 @@ func (s *Server) addMiddleware(next http.Handler) http.Handler { w.Header().Set("X-Explorer-Version", "1.0.0") w.Header().Set("X-Powered-By", "SolaceScanScout") - // Add CORS headers for API routes + // Add CORS headers for API routes (optional: set CORS_ALLOWED_ORIGIN to restrict, e.g. https://explorer.d-bis.org) if strings.HasPrefix(r.URL.Path, "/api/") { - w.Header().Set("Access-Control-Allow-Origin", "*") + origin := os.Getenv("CORS_ALLOWED_ORIGIN") + if origin == "" { + origin = "*" + } + w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, X-API-Key") @@ -99,10 +108,22 @@ func (s *Server) addMiddleware(next http.Handler) http.Handler { }) } +// requireDB returns false and writes 503 if db is nil (e.g. in tests without DB) +func (s *Server) requireDB(w http.ResponseWriter) bool { + if s.db == nil { + writeError(w, http.StatusServiceUnavailable, "service_unavailable", "database unavailable") + return false + } + return true +} + // handleListBlocks handles GET /api/v1/blocks func (s *Server) handleListBlocks(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + writeMethodNotAllowed(w) + return + } + if !s.requireDB(w) { return } @@ -132,7 +153,7 @@ func (s *Server) handleListBlocks(w http.ResponseWriter, r *http.Request) { rows, err := s.db.Query(ctx, query, s.chainID, pageSize, offset) if err != nil { - http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError) + writeInternalError(w, "Database error") return } defer rows.Close() @@ -191,12 +212,15 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) { w.Header().Set("X-Explorer-Version", "1.0.0") // Check database connection - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - dbStatus := "ok" - if err := s.db.Ping(ctx); err != nil { - dbStatus = "error: " + err.Error() + if s.db != nil { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + if err := s.db.Ping(ctx); err != nil { + dbStatus = "error: " + err.Error() + } + } else { + dbStatus = "unavailable" } health := map[string]interface{}{ diff --git a/backend/api/rest/stats.go b/backend/api/rest/stats.go index ab7bf32..8d12af0 100644 --- a/backend/api/rest/stats.go +++ b/backend/api/rest/stats.go @@ -10,7 +10,10 @@ import ( // handleStats handles GET /api/v2/stats func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + writeMethodNotAllowed(w) + return + } + if !s.requireDB(w) { return } diff --git a/backend/api/rest/transactions.go b/backend/api/rest/transactions.go index 762a484..6857426 100644 --- a/backend/api/rest/transactions.go +++ b/backend/api/rest/transactions.go @@ -13,7 +13,10 @@ import ( // handleListTransactions handles GET /api/v1/transactions func (s *Server) handleListTransactions(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + writeMethodNotAllowed(w) + return + } + if !s.requireDB(w) { return } @@ -70,7 +73,7 @@ func (s *Server) handleListTransactions(w http.ResponseWriter, r *http.Request) rows, err := s.db.Query(ctx, query, args...) if err != nil { - http.Error(w, fmt.Sprintf("Database error: %v", err), http.StatusInternalServerError) + writeInternalError(w, "Database error") return } defer rows.Close() @@ -178,7 +181,7 @@ func (s *Server) handleGetTransactionByHash(w http.ResponseWriter, r *http.Reque ) if err != nil { - http.Error(w, fmt.Sprintf("Transaction not found: %v", err), http.StatusNotFound) + writeNotFound(w, "Transaction") return } diff --git a/backend/api/search/search.go b/backend/api/search/search.go index 6223700..a69ac69 100644 --- a/backend/api/search/search.go +++ b/backend/api/search/search.go @@ -10,6 +10,7 @@ import ( "github.com/elastic/go-elasticsearch/v8" "github.com/elastic/go-elasticsearch/v8/esapi" + httperrors "github.com/explorer/backend/libs/go-http-errors" ) // SearchService handles unified search @@ -131,13 +132,13 @@ func (s *SearchService) parseResult(source map[string]interface{}) SearchResult // HandleSearch handles HTTP search requests func (s *SearchService) HandleSearch(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + httperrors.WriteJSON(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "Method not allowed") return } query := r.URL.Query().Get("q") if query == "" { - http.Error(w, "Query parameter 'q' is required", http.StatusBadRequest) + httperrors.WriteJSON(w, http.StatusBadRequest, "BAD_REQUEST", "Query parameter 'q' is required") return } @@ -157,7 +158,7 @@ func (s *SearchService) HandleSearch(w http.ResponseWriter, r *http.Request) { results, err := s.Search(r.Context(), query, chainID, limit) if err != nil { - http.Error(w, fmt.Sprintf("Search failed: %v", err), http.StatusInternalServerError) + httperrors.WriteJSON(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Search failed") return } diff --git a/backend/libs/go-http-errors/errors.go b/backend/libs/go-http-errors/errors.go new file mode 100644 index 0000000..ee43031 --- /dev/null +++ b/backend/libs/go-http-errors/errors.go @@ -0,0 +1,26 @@ +package httperrors + +import ( + "encoding/json" + "net/http" +) + +// ErrorResponse is the standard JSON error body for API responses. +type ErrorResponse struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` +} + +// WriteJSON writes a JSON error response with the given status code and message. +func WriteJSON(w http.ResponseWriter, statusCode int, code, message string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(statusCode) + json.NewEncoder(w).Encode(ErrorResponse{ + Error: struct { + Code string `json:"code"` + Message string `json:"message"` + }{Code: code, Message: message}, + }) +} diff --git a/frontend/public/explorer-spa.js b/frontend/public/explorer-spa.js new file mode 100644 index 0000000..bb7f8ca --- /dev/null +++ b/frontend/public/explorer-spa.js @@ -0,0 +1,3532 @@ + 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://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; + const EXPLORER_ORIGIN = (typeof window !== 'undefined' && window.location && EXPLORER_ORIGINS.includes(window.location.origin)) ? window.location.origin : 'https://explorer.d-bis.org'; + var I18N = { + en: { home: 'Home', blocks: 'Blocks', transactions: 'Transactions', bridge: 'Bridge', weth: 'WETH', tokens: 'Tokens', analytics: 'Analytics', operator: 'Operator', watchlist: 'Watchlist', searchPlaceholder: 'Address, tx hash, block number, or token/contract name...', connectWallet: 'Connect Wallet', darkMode: 'Dark mode', lightMode: 'Light mode', back: 'Back', exportCsv: 'Export CSV', tokenBalances: 'Token Balances', internalTxns: 'Internal Txns', readContract: 'Read contract', writeContract: 'Write contract', addToWatchlist: 'Add to watchlist', removeFromWatchlist: 'Remove from watchlist', checkApprovals: 'Check token approvals', copied: 'Copied' }, + de: { home: 'Start', blocks: 'Blöcke', transactions: 'Transaktionen', bridge: 'Brücke', weth: 'WETH', tokens: 'Token', analytics: 'Analysen', operator: 'Operator', watchlist: 'Beobachtungsliste', searchPlaceholder: 'Adresse, Tx-Hash, Blocknummer oder Token/Vertrag…', connectWallet: 'Wallet verbinden', darkMode: 'Dunkelmodus', lightMode: 'Hellmodus', back: 'Zurück', exportCsv: 'CSV exportieren', tokenBalances: 'Token-Bestände', internalTxns: 'Interne Transaktionen', readContract: 'Vertrag lesen', writeContract: 'Vertrag schreiben', addToWatchlist: 'Zur Beobachtungsliste', removeFromWatchlist: 'Aus Beobachtungsliste entfernen', checkApprovals: 'Token-Freigaben prüfen', copied: 'Kopiert' }, + fr: { home: 'Accueil', blocks: 'Blocs', transactions: 'Transactions', bridge: 'Pont', weth: 'WETH', tokens: 'Jetons', analytics: 'Analyses', operator: 'Opérateur', watchlist: 'Liste de suivi', searchPlaceholder: 'Adresse, hash de tx, numéro de bloc ou nom de token/contrat…', connectWallet: 'Connecter le portefeuille', darkMode: 'Mode sombre', lightMode: 'Mode clair', back: 'Retour', exportCsv: 'Exporter CSV', tokenBalances: 'Soldes de jetons', internalTxns: 'Transactions internes', readContract: 'Lire le contrat', writeContract: 'Écrire le contrat', addToWatchlist: 'Ajouter à la liste', removeFromWatchlist: 'Retirer de la liste', checkApprovals: 'Vérifier les approbations', copied: 'Copié' } + }; + var currentLocale = (function(){ try { return localStorage.getItem('explorerLocale') || 'en'; } catch(e){ return 'en'; } })(); + function t(key) { return (I18N[currentLocale] && I18N[currentLocale][key]) || I18N.en[key] || key; } + function setLocale(loc) { currentLocale = loc; try { localStorage.setItem('explorerLocale', loc); } catch(e){} if (typeof applyI18n === 'function') applyI18n(); } + function applyI18n() { document.querySelectorAll('[data-i18n]').forEach(function(el){ var k = el.getAttribute('data-i18n'); if (k) el.textContent = t(k); }); var searchIn = document.getElementById('searchInput'); if (searchIn) searchIn.placeholder = t('searchPlaceholder'); var localeSel = document.getElementById('localeSelect'); if (localeSel) localeSel.value = currentLocale; var wcBtn = document.getElementById('walletConnectBtn'); if (wcBtn) wcBtn.textContent = t('connectWallet'); } + window._renderWatchlist = function() { var container = document.getElementById('watchlistContent'); if (!container) return; var list = getWatchlist(); if (list.length === 0) { container.innerHTML = '

No addresses in watchlist. Open an address and click "Add to watchlist".

'; return; } var html = ''; list.forEach(function(addr){ var label = getAddressLabel(addr) || ''; html += ''; }); html += '
AddressLabel
' + escapeHtml(shortenHash(addr)) + '' + escapeHtml(label) + '
'; container.innerHTML = html; }; + var KNOWN_ADDRESS_LABELS = { '0x89dd12025bfcd38a168455a44b400e913ed33be2': 'CCIP WETH9 Bridge', '0xe0e93247376aa097db308b92e6ba36ba015535d0': 'CCIP WETH10 Bridge', '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2': 'WETH9', '0xf4bb2e28688e89fcce3c0580d37d36a7672e8a9f': 'WETH10', '0x8078a09637e47fa5ed34f626046ea2094a5cde5e': 'CCIP Router', '0x105f8a15b819948a89153505762444ee9f324684': 'CCIP Sender' }; + function getAddressLabel(addr) { if (!addr) return ''; var lower = addr.toLowerCase(); if (KNOWN_ADDRESS_LABELS[lower]) return KNOWN_ADDRESS_LABELS[lower]; try { var j = localStorage.getItem('explorerAddressLabels'); if (!j) return ''; var m = JSON.parse(j); return m[lower] || ''; } catch(e){ return ''; } } + function formatAddressWithLabel(addr) { if (!addr) return ''; var label = getAddressLabel(addr); return label ? escapeHtml(label) + ' (' + escapeHtml(shortenHash(addr)) + ')' : escapeHtml(shortenHash(addr)); } + function copyToClipboard(val, msg) { if (!val) return; try { navigator.clipboard.writeText(String(val)); showToast(msg || 'Copied', 'success'); } catch(e) { showToast('Copy failed', 'error'); } } + function setAddressLabel(addr, label) { try { var j = localStorage.getItem('explorerAddressLabels') || '{}'; var m = JSON.parse(j); m[addr.toLowerCase()] = (label || '').trim(); localStorage.setItem('explorerAddressLabels', JSON.stringify(m)); return true; } catch(e){ return false; } } + function getWatchlist() { try { var j = localStorage.getItem('explorerWatchlist'); if (!j) return []; var a = JSON.parse(j); return Array.isArray(a) ? a : []; } catch(e){ return []; } } + function addToWatchlist(addr) { if (!addr || !/^0x[a-fA-F0-9]{40}$/i.test(addr)) return false; var a = getWatchlist(); var lower = addr.toLowerCase(); if (a.indexOf(lower) === -1) { a.push(lower); try { localStorage.setItem('explorerWatchlist', JSON.stringify(a)); return true; } catch(e){} } return false; } + function removeFromWatchlist(addr) { var a = getWatchlist().filter(function(x){ return x !== addr.toLowerCase(); }); try { localStorage.setItem('explorerWatchlist', JSON.stringify(a)); return true; } catch(e){ return false; } } + function isInWatchlist(addr) { return getWatchlist().indexOf((addr || '').toLowerCase()) !== -1; } + let currentView = 'home'; + let 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 (we use path-based URLs). + function switchToView(viewName) { + if (viewName !== 'blocks' && _blocksScrollAnimationId != null) { + cancelAnimationFrame(_blocksScrollAnimationId); + _blocksScrollAnimationId = null; + } + currentView = viewName; + var detailViews = ['blockDetail','transactionDetail','addressDetail','tokenDetail','nftDetail','watchlist','searchResults','tokens']; + 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.showWatchlist = function() { if (_inNavHandler) return; _inNavHandler = true; try { switchToView('watchlist'); if (window._renderWatchlist) window._renderWatchlist(); } finally { _inNavHandler = false; } }; + window.showTokensList = function() { if (_inNavHandler) return; _inNavHandler = true; try { switchToView('tokens'); if (window._loadTokensList) window._loadTokensList(); } finally { _inNavHandler = false; } }; + window.showAnalytics = function() { if (_inNavHandler) return; _inNavHandler = true; try { switchToView('analytics'); if (window._showAnalytics) window._showAnalytics(); } finally { _inNavHandler = false; } }; + window.showOperator = function() { if (_inNavHandler) return; _inNavHandler = true; try { switchToView('operator'); if (window._showOperator) window._showOperator(); } finally { _inNavHandler = false; } }; + window.showBlockDetail = function(n) { if (window._showBlockDetail) window._showBlockDetail(n); }; + window.showTransactionDetail = function(h) { if (window._showTransactionDetail) window._showTransactionDetail(h); }; + window.showAddressDetail = function(a) { if (window._showAddressDetail) window._showAddressDetail(a); }; + 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'); + const moreWrap = document.getElementById('navDropdownMoreWrap'); + if (analyticsNav) analyticsNav.style.display = hasAccess(3) ? 'block' : 'none'; + if (operatorNav) operatorNav.style.display = hasAccess(4) ? 'block' : 'none'; + if (moreWrap) moreWrap.style.display = (hasAccess(3) || hasAccess(4)) ? '' : '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; + var creationTx = blockscoutAddr.creation_tx_hash || blockscoutAddr.creator_tx_hash || blockscoutAddr.creation_transaction_hash || null; + var firstSeen = blockscoutAddr.first_transaction_at || blockscoutAddr.first_seen_at || blockscoutAddr.first_tx_at || null; + var lastSeen = blockscoutAddr.last_transaction_at || blockscoutAddr.last_seen_at || blockscoutAddr.last_tx_at || null; + 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, + creation_tx_hash: creationTx, + first_seen_at: firstSeen, + last_seen_at: lastSeen + }; + } + + // Skeleton loader function + function createSkeletonLoader(type) { + switch(type) { + case 'stats': + return ` +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `; + case 'table': + return ` + + + + + + + + + + + ${Array(5).fill(0).map(() => ` + + + + + + + `).join('')} + +
+ `; + case 'detail': + return ` +
+
+
+
+
+ ${Array(8).fill(0).map(() => ` +
+
+
+
+
+
+
+
+ `).join('')} +
+
+ `; + default: + return '
Loading...
'; + } + } + + // WETH ABI (Standard ERC-20 + WETH functions) + const WETH_ABI = [ + "function deposit() payable", + "function withdraw(uint256 wad)", + "function balanceOf(address account) view returns (uint256)", + "function transfer(address to, uint256 amount) returns (bool)", + "function approve(address spender, uint256 amount) returns (bool)", + "function allowance(address owner, address spender) view returns (uint256)", + "function totalSupply() view returns (uint256)", + "function name() view returns (string)", + "function symbol() view returns (string)", + "function decimals() view returns (uint8)", + "event Deposit(address indexed dst, uint256 wad)", + "event Withdrawal(address indexed src, uint256 wad)" + ]; + + // Helper function to check if ethers is loaded + function ensureEthers() { + // Check immediately - ethers might already be loaded + if (typeof ethers !== 'undefined') { + window.ethersReady = true; + return Promise.resolve(true); + } + + // Wait for ethers to load if it's still loading + return new Promise((resolve, reject) => { + let resolved = false; + + // Check immediately first (double check) + if (typeof ethers !== 'undefined') { + window.ethersReady = true; + resolve(true); + return; + } + + // Wait for ethersReady event + const timeout = setTimeout(() => { + if (!resolved) { + resolved = true; + // Final check before rejecting + if (typeof ethers !== 'undefined') { + window.ethersReady = true; + resolve(true); + } else { + console.error('Ethers library failed to load after 20 seconds'); + reject(new Error('Ethers library failed to load. Please refresh the page.')); + } + } + }, 20000); // 20 second timeout + + const checkInterval = setInterval(() => { + if (typeof ethers !== 'undefined' && !resolved) { + resolved = true; + clearInterval(checkInterval); + clearTimeout(timeout); + window.ethersReady = true; + console.log('✅ Ethers detected via polling'); + resolve(true); + } + }, 100); + + // Listen for ethersReady event + const onReady = function() { + if (!resolved) { + resolved = true; + clearInterval(checkInterval); + clearTimeout(timeout); + window.removeEventListener('ethersReady', onReady); + if (typeof ethers !== 'undefined') { + window.ethersReady = true; + console.log('✅ Ethers ready via event'); + resolve(true); + } else { + console.error('ethersReady event fired but ethers is still undefined'); + reject(new Error('Ethers library is not loaded. Please refresh the page.')); + } + } + }; + + window.addEventListener('ethersReady', onReady, { once: true }); + }); + } + + // Helper function to get API URL based on chain ID + function getAPIUrl(endpoint) { + // For ChainID 138, use Blockscout API + if (CHAIN_ID === 138) { + return `${BLOCKSCOUT_API}${endpoint}`; + } + // For other networks, use v2 Etherscan/Blockscan APIs + return `${API_BASE}/v2${endpoint}`; + } + + // Initialize - only run once + let initialized = false; + document.addEventListener('DOMContentLoaded', async () => { + if (initialized) { + console.warn('Initialization already completed, skipping...'); + return; + } + initialized = true; + + applyStoredTheme(); + var localeSel = document.getElementById('localeSelect'); if (localeSel) localeSel.value = currentLocale; + if (typeof applyI18n === 'function') applyI18n(); + applyHashRoute(); + window.addEventListener('popstate', function() { applyHashRoute(); }); + window.addEventListener('hashchange', function() { applyHashRoute(); }); + window.addEventListener('load', function() { 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 = ` + + Connected: ${escapeHtml(shortenHash(userAddress))} + + `; + + // 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 = ` + + MetaMask not connected + + `; + + 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 = ' Processing...'; + + const tx = await weth9Contract.deposit({ value: amountWei }); + const receipt = await tx.wait(); + + btn.innerHTML = ' Success!'; + document.getElementById('weth9WrapAmount').value = ''; + await refreshWETHBalances(); + + setTimeout(() => { + btn.innerHTML = ' 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 = ' 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 = ' Processing...'; + + const tx = await weth9Contract.withdraw(amountWei); + const receipt = await tx.wait(); + + btn.innerHTML = ' Success!'; + document.getElementById('weth9UnwrapAmount').value = ''; + await refreshWETHBalances(); + + setTimeout(() => { + btn.innerHTML = ' 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 = ' 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 = ' Processing...'; + + const tx = await weth10Contract.deposit({ value: amountWei }); + const receipt = await tx.wait(); + + btn.innerHTML = ' Success!'; + document.getElementById('weth10WrapAmount').value = ''; + await refreshWETHBalances(); + + setTimeout(() => { + btn.innerHTML = ' 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 = ' 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 = ' Processing...'; + + const tx = await weth10Contract.withdraw(amountWei); + const receipt = await tx.wait(); + + btn.innerHTML = ' Success!'; + document.getElementById('weth10UnwrapAmount').value = ''; + await refreshWETHBalances(); + + setTimeout(() => { + btn.innerHTML = ' 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 = ' 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.pathname || '').replace(/^\//, '').split('/')[0] !== 'weth') updatePath('/weth'); + if (userAddress) { + await refreshWETHBalances(); + } + } + window._showWETHUtilities = showWETHUtilities; + + async function showBridgeMonitoring() { + showView('bridge'); + if ((window.location.pathname || '').replace(/^\//, '').split('/')[0] !== 'bridge') updatePath('/bridge'); + await refreshBridgeData(); + } + window._showBridgeMonitoring = showBridgeMonitoring; + + async function showHome() { + showView('home'); + if ((window.location.pathname || '').replace(/^\//, '').replace(/\/$/, '') !== 'home') updatePath('/home'); + await loadStats(); + await loadLatestBlocks(); + await loadLatestTransactions(); + // Start real-time transaction updates + startTransactionUpdates(); + } + window._showHome = showHome; + + async function showBlocks() { + showView('blocks'); + if ((window.location.pathname || '').replace(/^\//, '').split('/')[0] !== 'blocks') updatePath('/blocks'); + await loadAllBlocks(); + } + window._showBlocks = showBlocks; + + async function showTransactions() { + showView('transactions'); + if ((window.location.pathname || '').replace(/^\//, '').split('/')[0] !== 'transactions') updatePath('/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; + } + switchToView('analytics'); + } + 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; + } + switchToView('operator'); + } + window._showOperator = showOperator; + + function showView(viewName) { + currentView = viewName; + var detailViews = ['blockDetail','transactionDetail','addressDetail','tokenDetail','nftDetail','watchlist','searchResults','tokens']; + if (detailViews.indexOf(viewName) === -1) currentDetailKey = ''; + document.querySelectorAll('.detail-view').forEach(v => v.classList.remove('active')); + const homeView = document.getElementById('homeView'); + if (homeView) homeView.style.display = viewName === 'home' ? 'block' : 'none'; + if (viewName !== 'home') { + const targetView = document.getElementById(`${viewName}View`); + if (targetView) targetView.classList.add('active'); + stopTransactionUpdates(); + } + } + window.showView = showView; + + function toggleDarkMode() { + document.body.classList.toggle('dark-theme'); + var isDark = document.body.classList.contains('dark-theme'); + try { localStorage.setItem('explorerTheme', isDark ? 'dark' : 'light'); } catch (e) {} + var icon = document.getElementById('themeIcon'); + if (icon) { + icon.className = isDark ? 'fas fa-sun' : 'fas fa-moon'; + } + } + function applyStoredTheme() { + try { + var theme = localStorage.getItem('explorerTheme'); + if (theme === 'dark') { + document.body.classList.add('dark-theme'); + var icon = document.getElementById('themeIcon'); + if (icon) icon.className = 'fas fa-sun'; + } + } catch (e) {} + } + function updatePath(path) { + if (typeof history !== 'undefined' && history.pushState) { + history.pushState(null, '', path); + } + } + window.updatePath = updatePath; + function applyHashRoute() { + var route = ''; + var fromPath = (window.location.pathname || '/').replace(/^\//, '').replace(/\/$/, '').replace(/^index\.html$/i, ''); + var fromHash = (window.location.hash || '').replace(/^#/, ''); + if (fromPath && fromPath !== '') { + route = fromPath; + } else if (fromHash) { + route = fromHash; + } + if (!route) { showHome(); updatePath('/home'); return; } + var parts = route.split('/').filter(Boolean); + var decode = function(s) { try { return decodeURIComponent(s); } catch (e) { return s; } }; + if (parts[0] === 'block' && parts[1]) { var p1 = decode(parts[1]); 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] === 'watchlist') { if (currentView !== 'watchlist') showWatchlist(); return; } + if (parts[0] === 'tokens') { if (typeof showTokensList === 'function') showTokensList(); else focusSearchWithHint('token'); return; } + if (parts[0] === 'analytics') { if (currentView !== 'analytics') showAnalytics(); return; } + if (parts[0] === 'operator') { if (currentView !== 'operator') showOperator(); return; } + } + window.applyHashRoute = applyHashRoute; + var hasRouteOnReady = window.location.hash || ((window.location.pathname || '').replace(/^\//, '').replace(/\/$/, '')); + if (document.readyState !== 'loading' && hasRouteOnReady) { applyHashRoute(); } + window.toggleDarkMode = toggleDarkMode; + + function focusSearchWithHint(kind) { + 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'; + closeAllNavDropdowns(); + } + function closeAllNavDropdowns() { + document.querySelectorAll('.nav-dropdown.open').forEach(function (el) { + el.classList.remove('open'); + var trigger = el.querySelector('.nav-dropdown-trigger'); + if (trigger) trigger.setAttribute('aria-expanded', 'false'); + }); + } + function initNavDropdowns() { + document.querySelectorAll('.nav-dropdown-trigger').forEach(function (trigger) { + trigger.addEventListener('click', function (e) { + e.preventDefault(); + e.stopPropagation(); + var dropdown = trigger.closest('.nav-dropdown'); + var wasOpen = dropdown && dropdown.classList.contains('open'); + closeAllNavDropdowns(); + if (dropdown && !wasOpen) { + dropdown.classList.add('open'); + trigger.setAttribute('aria-expanded', 'true'); + } + }); + }); + document.addEventListener('click', function () { + closeAllNavDropdowns(); + }); + document.getElementById('navLinks').addEventListener('click', function (e) { + if (e.target.closest('.nav-dropdown-menu')) e.stopPropagation(); + }); + } + window.toggleNavMenu = toggleNavMenu; + window.closeNavMenu = closeNavMenu; + window.closeAllNavDropdowns = closeAllNavDropdowns; + + // Update breadcrumb navigation + function updateBreadcrumb(type, identifier, identifierExtra) { + let breadcrumbContainer; + let breadcrumbHTML = 'Home'; + switch (type) { + case 'block': + breadcrumbContainer = document.getElementById('blockDetailBreadcrumb'); + breadcrumbHTML += '/'; + breadcrumbHTML += 'Blocks'; + breadcrumbHTML += '/'; + breadcrumbHTML += 'Block #' + escapeHtml(String(identifier)) + ''; + break; + case 'transaction': + breadcrumbContainer = document.getElementById('transactionDetailBreadcrumb'); + breadcrumbHTML += '/'; + breadcrumbHTML += 'Transactions'; + breadcrumbHTML += '/'; + breadcrumbHTML += '' + escapeHtml(shortenHash(identifier)) + ''; + break; + case 'address': + breadcrumbContainer = document.getElementById('addressDetailBreadcrumb'); + breadcrumbHTML += '/Address/' + escapeHtml(shortenHash(identifier)) + ''; + break; + case 'token': + breadcrumbContainer = document.getElementById('tokenDetailBreadcrumb'); + breadcrumbHTML += '/'; + breadcrumbHTML += 'Tokens'; + breadcrumbHTML += '/'; + breadcrumbHTML += 'Token ' + escapeHtml(shortenHash(identifier)) + ''; + break; + case 'nft': + breadcrumbContainer = document.getElementById('nftDetailBreadcrumb'); + breadcrumbHTML += '/'; + breadcrumbHTML += '' + escapeHtml(shortenHash(identifier)) + ''; + breadcrumbHTML += '/'; + breadcrumbHTML += 'Token ID ' + (identifierExtra != null ? escapeHtml(String(identifierExtra)) : '') + ''; + 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 = ` +
+
Total Blocks
+
${formatNumber(stats.total_blocks || 0)}
+
+
+
Total Transactions
+
${formatNumber(stats.total_transactions || 0)}
+
+
+
Total Addresses
+
${formatNumber(stats.total_addresses || 0)}
+
+
+
Bridge Contracts
+
2 Active
+
+
+
Network
+
Loading...
+
+ `; + if (CHAIN_ID === 138) loadGasAndNetworkStats(); + } catch (error) { + console.error('Failed to load stats:', error); + statsGrid.innerHTML = ` +
+
Total Blocks
+
-
+
+
+
Total Transactions
+
-
+
+
+
Total Addresses
+
-
+
+
+
Bridge Contracts
+
2 Active
+
+
Network
-
+ `; + } + } + + async function loadGasAndNetworkStats() { + var el = document.getElementById('networkStatValue'); + var gasCard = document.getElementById('gasNetworkCard'); + var gasContent = document.getElementById('gasNetworkContent'); + try { + var blocksResp = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/blocks?page=1&page_size=20'); + var blocks = blocksResp.items || blocksResp || []; + var statsResp = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/stats').catch(function() { return {}; }); + var gasGwei = '-'; + var blockTimeSec = '-'; + var tps = '-'; + if (blocks.length > 0) { + var b = blocks[0]; + var baseFee = b.base_fee_per_gas || b.base_fee; + if (baseFee != null) gasGwei = (Number(baseFee) / 1e9).toFixed(2) + ' Gwei'; + } + if (blocks.length >= 2) { + var t0 = blocks[0].timestamp; + var t1 = blocks[1].timestamp; + if (t0 && t1) { + var d = (new Date(t0) - new Date(t1)) / 1000; + if (d > 0) blockTimeSec = d.toFixed(1) + 's'; + } + } + if (statsResp.average_block_time != null) blockTimeSec = Number(statsResp.average_block_time).toFixed(1) + 's'; + if (statsResp.transactions_per_second != null) tps = Number(statsResp.transactions_per_second).toFixed(2); + if (el) el.innerHTML = (gasGwei !== '-' ? 'Gas: ' + escapeHtml(gasGwei) + '
' : '') + (blockTimeSec !== '-' ? 'Block: ' + escapeHtml(blockTimeSec) + '
' : '') + (tps !== '-' ? 'TPS: ' + escapeHtml(tps) : '') || 'Gas / TPS'; + if (gasCard && gasContent && CHAIN_ID === 138) { + var curEl = document.getElementById('gasCurrentValue'); + var tpsEl = document.getElementById('gasTpsValue'); + var btEl = document.getElementById('gasBlockTimeValue'); + var failedEl = document.getElementById('gasFailedRateValue'); + var barsEl = document.getElementById('gasHistoryBars'); + if (curEl) curEl.textContent = gasGwei !== '-' ? gasGwei : '—'; + if (tpsEl) tpsEl.textContent = tps !== '-' ? tps : '—'; + if (btEl) btEl.textContent = blockTimeSec !== '-' ? blockTimeSec : '—'; + if (failedEl) { + var txsResp = await fetch(BLOCKSCOUT_API + '/v2/transactions?page=1&page_size=100').then(function(r) { return r.json(); }).catch(function() { return { items: [] }; }); + var txs = txsResp.items || txsResp || []; + var failed = txs.filter(function(t) { var s = t.status; return s === 0 || s === '0' || (t.block && t.block.success === false); }).length; + failedEl.textContent = txs.length > 0 ? (100 * failed / txs.length).toFixed(2) + '%' : '—'; + } + if (barsEl) { + var recent = blocks.slice(0, 10); + var fees = recent.map(function(bl) { var f = bl.base_fee_per_gas || bl.base_fee; return f != null ? Number(f) / 1e9 : 0; }); + var maxFee = Math.max.apply(null, fees) || 1; + barsEl.innerHTML = fees.map(function(g, i) { + var pct = maxFee > 0 ? (g / maxFee * 100) : 0; + return ''; + }).join(''); + } + } + } catch (e) { + if (el) 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 '
' + + '
#' + escapeHtml(String(d.blockNum)) + '
' + + '
' + escapeHtml(shortenHash(d.hash)) + '
' + + '
' + + '
Transactions' + escapeHtml(String(d.txCount)) + '
' + + '
Time' + escapeHtml(d.timeAgo) + '
' + + '
'; + } + + // 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 = '
API temporarily unavailable. ' + escapeHtml((blockscoutError.message || 'Unknown error').substring(0, 150)) + '
'; + } + return; + } + if (blocks.length === 0 && container) { + container.innerHTML = '
Could not load blocks.
'; + 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 = '
No blocks found.
'; + } else { + // Create HTML with duplicated blocks for seamless infinite loop + let html = '
'; + html += '
'; + + // 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 += '
'; + 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 = '
Failed to load blocks: ' + escapeHtml((error.message || 'Unknown error').substring(0, 200)) + '.
'; + } 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 = '
API temporarily unavailable.
'; + 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 = ''; + + if (limitedTransactions.length === 0) { + html += ''; + } 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 += ''; + }); + } + + html += '
HashFromToValueBlock
No transactions found
' + escapeHtml(shortenHash(hash)) + '' + formatAddressWithLabel(from) + '' + (to ? formatAddressWithLabel(to) : '-') + '' + escapeHtml(valueFormatted) + ' ETH' + escapeHtml(String(blockNumber)) + '
'; + 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 = '
Failed to load transactions: ' + escapeHtml((error.message || 'Unknown error').substring(0, 200)) + '.
'; + } + } + + // Real-time transaction updates + function startTransactionUpdates() { + // Clear any existing interval + if (transactionUpdateInterval) { + clearInterval(transactionUpdateInterval); + } + + // Update transactions every 5 seconds + transactionUpdateInterval = setInterval(() => { + if (currentView === 'home') { + loadLatestTransactions(); + } + }, 5000); + } + + function stopTransactionUpdates() { + if (transactionUpdateInterval) { + clearInterval(transactionUpdateInterval); + transactionUpdateInterval = null; + } + } + + var blocksListPage = 1; + var transactionsListPage = 1; + const LIST_PAGE_SIZE = 25; + + async function loadAllBlocks(page) { + if (page != null) blocksListPage = Math.max(1, parseInt(page, 10) || 1); + const container = document.getElementById('blocksList'); + if (!container) { return; } + + try { + container.innerHTML = '
Loading blocks...
'; + + let blocks = []; + + // For ChainID 138, use Blockscout API + if (CHAIN_ID === 138) { + try { + const response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/blocks?page=${blocksListPage}&page_size=${LIST_PAGE_SIZE}`); + 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 = ''; + + if (blocks.length === 0) { + html += ''; + } else { + blocks.forEach(function(block) { + var d = normalizeBlockDisplay(block); + html += ''; + }); + } + + var pagination = '
'; + pagination += 'Page ' + blocksListPage + ''; + pagination += '
'; + html += '
BlockHashTransactionsTimestamp
No blocks found
' + escapeHtml(String(d.blockNum)) + '' + escapeHtml(shortenHash(d.hash)) + '' + escapeHtml(String(d.txCount)) + '' + escapeHtml(d.timestampFormatted) + '
' + pagination; + container.innerHTML = html; + } catch (error) { + container.innerHTML = '
Failed to load blocks: ' + escapeHtml(error.message || 'Unknown error') + '.
'; + } + } + + async function loadAllTransactions(page) { + if (page != null) transactionsListPage = Math.max(1, parseInt(page, 10) || 1); + const container = document.getElementById('transactionsList'); + if (!container) { return; } + + try { + container.innerHTML = '
Loading transactions...
'; + + let transactions = []; + + // For ChainID 138, use Blockscout API + if (CHAIN_ID === 138) { + try { + const response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/transactions?page=${transactionsListPage}&page_size=${LIST_PAGE_SIZE}`); + 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 = ''; + + if (transactions.length === 0) { + html += ''; + } 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 += ''; + }); + } + + var pagination = '
'; + pagination += 'Page ' + transactionsListPage + ''; + pagination += '
'; + html += '
HashFromToValueBlock
No transactions found
' + escapeHtml(shortenHash(hash)) + '' + formatAddressWithLabel(from) + '' + (to ? formatAddressWithLabel(to) : '-') + '' + escapeHtml(valueFormatted) + ' ETH' + escapeHtml(String(blockNumber)) + '
' + pagination; + container.innerHTML = html; + } catch (error) { + container.innerHTML = '
Failed to load transactions: ' + escapeHtml(error.message || 'Unknown error') + '.
'; + } + } + + async function loadTokensList() { + var container = document.getElementById('tokensListContent'); + if (!container) return; + try { + container.innerHTML = '
Loading tokens...
'; + if (CHAIN_ID === 138) { + try { + var resp = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/tokens?page=1&page_size=100').catch(function() { return null; }); + var items = (resp && (resp.items || resp.data)) || (Array.isArray(resp) ? resp : null); + if (items && items.length > 0) { + var html = ''; + items.forEach(function(t) { + var addr = (t.address && (t.address.hash || t.address)) || t.address_hash || t.token_address || t.contract_address_hash || ''; + var name = t.name || t.symbol || '-'; + var symbol = t.symbol || ''; + var type = t.type || 'ERC-20'; + if (!addr) return; + html += ''; + }); + html += '
TokenContractType
' + escapeHtml(name) + (symbol ? ' (' + escapeHtml(symbol) + ')' : '') + '' + escapeHtml(shortenHash(addr)) + '' + escapeHtml(type) + '
'; + container.innerHTML = html; + return; + } + } catch (e) {} + } + container.innerHTML = '

No token index available. Use the search bar to find tokens by name, symbol, or contract address (0x...).

'; + } catch (err) { + container.innerHTML = '
Failed to load tokens. Use the search bar to find a token by address or name.
'; + } + } + window._loadTokensList = loadTokensList; + + async function refreshBridgeData() { + const container = document.getElementById('bridgeContent'); + if (!container) return; + + try { + container.innerHTML = '
Loading bridge data...
'; + + // Chain 138 Bridge Contracts + const WETH9_BRIDGE_138 = '0x971cD9D156f193df8051E48043C476e53ECd4693'; + const WETH10_BRIDGE_138 = '0xe0E93247376aa097dB308B92e6Ba36bA015535D0'; + + // Ethereum Mainnet Bridge Contracts + const WETH9_BRIDGE_MAINNET = '0x2A0840e5117683b11682ac46f5CF5621E67269E3'; + const WETH10_BRIDGE_MAINNET = '0xb7721dD53A8c629d9f1Ba31a5819AFe250002b03'; + + // 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 = ` +
+
CCIP Bridge Ecosystem
+
+ Cross-chain interoperability powered by Chainlink CCIP +
+
+ + +
+
+

Chain 138 (Source Chain)

+
+
+
+
CCIPWETH9Bridge
+
+ ${WETH9_BRIDGE_138} +
+
+ Token: WETH9 +
+
+
+
CCIPWETH10Bridge
+
+ ${WETH10_BRIDGE_138} +
+
+ Token: WETH10 +
+
+
+
+ + +
+
+

CCIPWETH9Bridge Routes

+ 7 Destinations +
+
+ + + + + + + + + + `; + + // Add WETH9 routes + for (const [chain, address] of Object.entries(routes.weth9)) { + const chainId = chain.match(/\\((\d+)\\)/)?.[1] || ''; + html += ` + + + + + + `; + } + + html += ` + +
Destination ChainChain IDBridge Address
${chain.replace(/\s*\\(\\d+\\)/, '')}${chainId}${escapeHtml(shortenHash(address))}
+
+
+ + +
+
+

CCIPWETH10Bridge Routes

+ 7 Destinations +
+
+ + + + + + + + + + `; + + // Add WETH10 routes + for (const [chain, address] of Object.entries(routes.weth10)) { + const chainId = chain.match(/\\((\d+)\\)/)?.[1] || ''; + html += ` + + + + + + `; + } + + html += ` + +
Destination ChainChain IDBridge Address
${chain.replace(/\s*\\(\\d+\\)/, '')}${chainId}${escapeHtml(shortenHash(address))}
+
+
+ + +
+
+

Ethereum Mainnet Bridges

+
+
+
+
CCIPWETH9Bridge
+
+ ${WETH9_BRIDGE_MAINNET} +
+ +
+
+
CCIPWETH10Bridge
+
+ ${WETH10_BRIDGE_MAINNET} +
+ +
+
+
+ + +
+

Bridge Information

+
+

CCIP Bridge Ecosystem enables cross-chain transfers of WETH9 and WETH10 tokens using Chainlink CCIP (Cross-Chain Interoperability Protocol).

+ +

Supported Networks:

+
    +
  • Chain 138 - Source chain with both bridge contracts
  • +
  • Ethereum Mainnet - Destination with dedicated bridge contracts
  • +
  • BSC - Binance Smart Chain
  • +
  • Polygon - Polygon PoS
  • +
  • Avalanche - Avalanche C-Chain
  • +
  • Base - Base L2
  • +
  • Arbitrum - Arbitrum One
  • +
  • Optimism - Optimism Mainnet
  • +
+ +

How to Use:

+
    +
  1. Click on any bridge address to view detailed information and transaction history
  2. +
  3. Use the bridge contracts to transfer WETH9 or WETH10 tokens between supported chains
  4. +
  5. All transfers are secured by Chainlink CCIP infrastructure
  6. +
+ +

CCIP Infrastructure:

+
    +
  • CCIP Router (Chain 138): 0x8078A09637e47Fa5Ed34F626046Ea2094a5CDE5e
  • +
  • CCIP Sender (Chain 138): 0x105F8A15b819948a89153505762444Ee9f324684
  • +
+
+
+ `; + + container.innerHTML = html; + } catch (error) { + container.innerHTML = '
Failed to load bridge data: ' + escapeHtml(error.message) + '
'; + } + } + + 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'); + updatePath('/block/' + blockNumber); + const container = document.getElementById('blockDetail'); + updateBreadcrumb('block', blockNumber); + container.innerHTML = createSkeletonLoader('detail'); + + try { + let b; + + // For ChainID 138, use Blockscout API directly + var rawBlockResponse = null; + if (CHAIN_ID === 138) { + try { + const response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/blocks/${blockNumber}`); + rawBlockResponse = response; + b = normalizeBlock(response); + if (!b) { + throw new Error('Block not found'); + } + } catch (error) { + container.innerHTML = '
Failed to load block: ' + escapeHtml(error.message || 'Unknown error') + '.
'; + 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 = ` +
+

Block #${b.number}

+ +
+
+
Block Number
+
${b.number}
+
+
+
Hash
+
${escapeHtml(b.hash || '')}
+
+
+
Parent Hash
+
${escapeHtml(b.parent_hash || '')}
+
+
+
Timestamp
+
${escapeHtml(timestamp)}
+
+
+
Miner
+
${formatAddressWithLabel(b.miner || '') || 'N/A'}
+
+
+
Transaction Count
+
${b.transaction_count || 0}
+
+
+
Gas Used
+
${formatNumber(b.gas_used || 0)} / ${formatNumber(b.gas_limit || 0)} (${gasUsedPercent}%)
+
+
+
Gas Limit
+
${formatNumber(b.gas_limit || 0)}
+
+ ${b.base_fee_per_gas ? ` +
+
Base Fee
+
${baseFeeGwei} Gwei
+
+ ` : ''} + ${b.burnt_fees && parseInt(b.burnt_fees) > 0 ? ` +
+
Burnt Fees
+
${burntFeesEth} ETH
+
+ ` : ''} +
+
Size
+
${formatNumber(b.size || 0)} bytes
+
+
+
Difficulty
+
${formatNumber(b.difficulty || 0)}
+
+
+
Nonce
+
${escapeHtml(String(b.nonce || '0x0'))}
+
+ ${(rawBlockResponse && (rawBlockResponse.consensus !== undefined || rawBlockResponse.finality !== undefined || rawBlockResponse.validated !== undefined)) ? '
Finality / Consensus
' + escapeHtml(String(rawBlockResponse.consensus != null ? rawBlockResponse.consensus : (rawBlockResponse.finality != null ? rawBlockResponse.finality : rawBlockResponse.validated))) + '
' : ''} + `; + } else { + container.innerHTML = '
Block not found
'; + } + } catch (error) { + container.innerHTML = '
Failed to load block: ' + escapeHtml(error.message) + '
'; + } + } + window._showBlockDetail = showBlockDetail; + 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'); + updatePath('/tx/' + txHash); + const container = document.getElementById('transactionDetail'); + updateBreadcrumb('transaction', txHash); + container.innerHTML = createSkeletonLoader('detail'); + + try { + let t; + let rawTx = null; + + if (CHAIN_ID === 138) { + try { + 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 = '
Failed to load transaction: ' + escapeHtml(error.message || 'Unknown error') + '.
'; + 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 = '
Transaction not found
'; + return; + } + + const timestamp = new Date(t.created_at).toLocaleString(); + const valueEth = formatEther(t.value || '0'); + const gasPriceGwei = t.gas_price ? (parseInt(t.gas_price) / 1e9).toFixed(2) : 'N/A'; + const maxFeeGwei = t.max_fee_per_gas ? (parseInt(t.max_fee_per_gas) / 1e9).toFixed(2) : 'N/A'; + const priorityFeeGwei = t.max_priority_fee_per_gas ? (parseInt(t.max_priority_fee_per_gas) / 1e9).toFixed(2) : 'N/A'; + const burntFeeEth = t.tx_burnt_fee ? formatEther(t.tx_burnt_fee) : '0'; + const totalFee = t.gas_used && t.gas_price ? formatEther((BigInt(t.gas_used) * BigInt(t.gas_price)).toString()) : '0'; + const txType = t.type === 2 ? 'EIP-1559' : t.type === 1 ? 'EIP-2930' : 'Legacy'; + const revertReason = t.revert_reason || (rawTx && (rawTx.revert_reason || rawTx.error || rawTx.result)); + const inputHex = (t.input && t.input !== '0x') ? t.input : null; + const decodedInput = t.decoded_input || (rawTx && rawTx.decoded_input); + const toCellContent = t.to ? formatAddressWithLabel(t.to) + ' ' : 'N/A'; + + let mainHtml = ` +
+

Transaction

+
+ + +
+
+
+
Transaction Hash
+
${escapeHtml(t.hash)}
+
+
+
Type
+
${txType}
+
+
+
Status
+
+ + ${t.status === 1 ? 'Success' : t.status === 0 ? 'Failed' : 'Pending'} + +
+
+
+
Block Number
+
${escapeHtml(String(t.block_number || 'N/A'))}
+
+
+
Block Hash
+
${escapeHtml(t.block_hash || 'N/A')}
+
+
+
From
+
${formatAddressWithLabel(t.from || '')}
+
+
+
To
+
${toCellContent}
+
+
+
Value
+
${valueEth} ETH
+
+
+
Gas Used
+
${t.gas_used ? formatNumber(t.gas_used) : 'N/A'}
+
+
+
Gas Limit
+
${t.gas_limit ? formatNumber(t.gas_limit) : 'N/A'}
+
+ ${t.max_fee_per_gas ? `
Max Fee Per Gas
${maxFeeGwei} Gwei
` : ''} + ${t.max_priority_fee_per_gas ? `
Max Priority Fee
${priorityFeeGwei} Gwei
` : ''} + ${!t.max_fee_per_gas && t.gas_price ? `
Gas Price
${gasPriceGwei} Gwei
` : ''} +
+
Total Fee
+
${totalFee} ETH
+
+ ${t.tx_burnt_fee && parseInt(t.tx_burnt_fee) > 0 ? `
Burnt Fee
${burntFeeEth} ETH
` : ''} +
Nonce
${t.nonce || 'N/A'}
+
Timestamp
${timestamp}
+ ${t.contract_address ? `
Contract Address
${escapeHtml(t.contract_address)}
` : ''} + `; + + if (revertReason && t.status !== 1) { + const reasonStr = typeof revertReason === 'string' ? revertReason : (revertReason.message || JSON.stringify(revertReason)); + mainHtml += ` +
+

Revert Reason

+
${escapeHtml(reasonStr)}
+
+ `; + } + + if (inputHex || decodedInput) { + mainHtml += `

Input Data

`; + if (decodedInput && (decodedInput.method || decodedInput.params)) { + const method = decodedInput.method || decodedInput.name || 'Unknown'; + mainHtml += `

Method: ${escapeHtml(method)}

`; + if (decodedInput.params && Array.isArray(decodedInput.params)) { + mainHtml += ''; + decodedInput.params.forEach(function(p) { + const name = (p.name || p.type || ''); + const val = typeof p.value !== 'undefined' ? String(p.value) : (p.type || ''); + mainHtml += ''; + }); + mainHtml += '
ParamValue
' + escapeHtml(name) + '' + escapeHtml(val) + '
'; + } + } + if (inputHex) { + mainHtml += `

Hex: ${escapeHtml(inputHex)}

`; + } + mainHtml += '
'; + } + + container.innerHTML = mainHtml; + + if (CHAIN_ID === 138) { + const internalCard = document.createElement('div'); + internalCard.className = 'card'; + internalCard.style.marginTop = '1rem'; + internalCard.innerHTML = '

Internal Transactions

Loading...
'; + container.appendChild(internalCard); + + const logsCard = document.createElement('div'); + logsCard.className = 'card'; + logsCard.style.marginTop = '1rem'; + logsCard.innerHTML = '

Event Logs

Loading...
'; + 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 = '

No internal transactions

'; + } else { + let tbl = ''; + 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 += ''; + }); + tbl += '
TypeFromToValue
' + escapeHtml(type) + '' + escapeHtml(shortenHash(from)) + '' + escapeHtml(shortenHash(to)) + '' + escapeHtml(val) + ' ETH
'; + internalEl.innerHTML = tbl; + } + } + + const logsEl = document.getElementById('txLogs'); + if (logsEl) { + if (logs.length === 0) { + logsEl.innerHTML = '

No event logs

'; + } else { + let tbl = ''; + logs.forEach(function(log, idx) { + const addr = log.address?.hash || log.address || 'N/A'; + const topics = (log.topics && Array.isArray(log.topics)) ? log.topics : (log.topic0 ? [log.topic0] : []); + const topicsStr = topics.join(', '); + const data = log.data || log.raw_data || '0x'; + tbl += ''; + }); + tbl += '
AddressTopicsDataDecoded
' + escapeHtml(shortenHash(addr)) + '' + escapeHtml(String(topicsStr).substring(0, 80)) + (String(topicsStr).length > 80 ? '...' : '') + '' + escapeHtml(String(data).substring(0, 66)) + (String(data).length > 66 ? '...' : '') + '
'; + logsEl.innerHTML = tbl; + if (typeof ethers !== 'undefined' && ethers.utils) { + (function(logsList, txHash) { + var addrs = []; + logsList.forEach(function(l) { var a = l.address && (l.address.hash || l.address) || l.address; if (a && addrs.indexOf(a) === -1) addrs.push(a); }); + var abiCache = {}; + Promise.all(addrs.map(function(addr) { + if (!/^0x[a-f0-9]{40}$/i.test(addr)) return Promise.resolve(); + return fetch(BLOCKSCOUT_API + '/v2/smart-contracts/' + addr).then(function(r) { return r.json(); }).catch(function() { return null; }).then(function(res) { + var abi = res && (res.abi || res.abi_json); + if (abi) abiCache[addr.toLowerCase()] = Array.isArray(abi) ? abi : (typeof abi === 'string' ? JSON.parse(abi) : abi); + }); + })).then(function() { + logsList.forEach(function(log, idx) { + var addr = (log.address && (log.address.hash || log.address)) || log.address; + var topics = log.topics && Array.isArray(log.topics) ? log.topics : (log.topic0 ? [log.topic0] : []); + var data = log.data || log.raw_data || '0x'; + var abi = addr ? abiCache[(addr + '').toLowerCase()] : null; + var decodedEl = document.getElementById('txLogDecoded' + idx); + if (!decodedEl || !abi) return; + try { + var iface = new ethers.utils.Interface(abi); + var parsed = iface.parseLog({ topics: topics, data: data }); + if (parsed && parsed.name) { + var args = parsed.args && parsed.args.length ? parsed.args.map(function(a) { return String(a); }).join(', ') : ''; + decodedEl.textContent = parsed.name + '(' + args + ')'; + decodedEl.title = parsed.signature || ''; + } + } catch (e) {} + }); + }); + })(logs, txHash); + } + } + } + }); + } + } catch (error) { + container.innerHTML = '
Failed to load transaction: ' + escapeHtml(error.message) + '
'; + } + } + 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'); }); + } + function exportAddressTransactionsCSV(addr) { + if (!addr || !/^0x[a-fA-F0-9]{40}$/i.test(addr)) { showToast('Invalid address', 'error'); return; } + fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/transactions?address=' + encodeURIComponent(addr) + '&page=1&page_size=100').then(function(r) { + var items = r.items || r || []; + var rows = [['Hash', 'From', 'To', 'Value', 'Block', 'Status']]; + items.forEach(function(tx) { + var t = normalizeTransaction(tx); + if (t) rows.push([t.hash || '', t.from || '', t.to || '', t.value || '0', String(t.block_number || ''), t.status === 1 ? 'Success' : 'Failed']); + }); + var csv = rows.map(function(row) { return row.map(function(cell) { return '"' + String(cell).replace(/"/g, '""') + '"'; }).join(','); }).join('\n'); + var blob = new Blob([csv], { type: 'text/csv' }); + var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'address-' + addr.substring(0, 10) + '-transactions.csv'; a.click(); + URL.revokeObjectURL(a.href); + showToast('CSV downloaded', 'success'); + }).catch(function(e) { showToast('Export failed: ' + (e.message || 'Unknown'), 'error'); }); + } + window.exportAddressTransactionsCSV = exportAddressTransactionsCSV; + function exportAddressTokenBalancesCSV(addr) { + if (!addr || !/^0x[a-fA-F0-9]{40}$/i.test(addr)) { showToast('Invalid address', 'error'); return; } + fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/addresses/' + addr + '/token-balances').catch(function() { return { items: [] }; }).then(function(r) { + var items = Array.isArray(r) ? r : (r.items || r || []); + var rows = [['Token', 'Contract', 'Balance', 'Type']]; + (items || []).forEach(function(b) { + var token = b.token || b; + var contract = token.address?.hash || token.address || b.token_contract_address_hash || 'N/A'; + var symbol = token.symbol || token.name || '-'; + var balance = b.value || b.balance || '0'; + var decimals = token.decimals != null ? token.decimals : 18; + var divisor = Math.pow(10, parseInt(decimals, 10)); + var displayBalance = (Number(balance) / divisor).toLocaleString(undefined, { maximumFractionDigits: 6 }); + var type = token.type || b.token_type || 'ERC-20'; + rows.push([symbol, contract, displayBalance, type]); + }); + var csv = rows.map(function(row) { return row.map(function(cell) { return '"' + String(cell).replace(/"/g, '""') + '"'; }).join(','); }).join('\n'); + var blob = new Blob([csv], { type: 'text/csv' }); + var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = 'address-' + addr.substring(0, 10) + '-token-balances.csv'; a.click(); + URL.revokeObjectURL(a.href); + showToast('CSV downloaded', 'success'); + }).catch(function(e) { showToast('Export failed: ' + (e.message || 'Unknown'), 'error'); }); + } + window.exportAddressTokenBalancesCSV = exportAddressTokenBalancesCSV; + window._showTransactionDetail = showTransactionDetail; + 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'); + updatePath('/address/' + address); + const container = document.getElementById('addressDetail'); + updateBreadcrumb('address', address); + container.innerHTML = createSkeletonLoader('detail'); + + try { + // Validate address format + if (!/^0x[a-fA-F0-9]{40}$/.test(address)) { + container.innerHTML = '
Invalid address format
'; + 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 = '
Failed to load address: ' + escapeHtml(error.message || 'Unknown error') + '.
'; + 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 ? 'Verified' : ''; + const contractLink = isContract ? `View contract on Blockscout` : ''; + const savedLabel = getAddressLabel(address); + const inWatchlist = isInWatchlist(address); + + container.innerHTML = ` +
+
Address
+
${address}
+
+
+
Label
+
+
+
+
Watchlist
+
+
+
+
Token approvals
+ +
+
+
Balance
+
${balanceEth} ETH
+
+
+
Transaction Count
+
${a.transaction_count || 0}
+
+
+
Token Count
+
${a.token_count || 0}
+
+
+
Type
+
${a.is_contract ? 'Contract' + verifiedBadge + (contractLink ? '
' + contractLink : '') : 'EOA'}
+
+ ${a.creation_tx_hash ? `
Contract created in
${escapeHtml(shortenHash(a.creation_tx_hash))}
` : ''} + ${a.first_seen_at ? `
First seen
${escapeHtml(typeof a.first_seen_at === 'string' ? a.first_seen_at : new Date(a.first_seen_at).toISOString())}
` : ''} + ${a.last_seen_at ? `
Last seen
${escapeHtml(typeof a.last_seen_at === 'string' ? a.last_seen_at : new Date(a.last_seen_at).toISOString())}
` : ''} +
+ + + + + ${isContract ? '' : ''} +
+
+
+

Recent Transactions

+ +
+
Loading transactions...
+
+ + + + ${isContract ? '' : ''} + `; + + function switchAddressTab(tabName, addr) { + document.querySelectorAll('.address-tab-content').forEach(function(el) { el.style.display = 'none'; }); + document.querySelectorAll('.tabs .tab').forEach(function(t) { t.classList.remove('active'); }); + if (tabName === 'transactions') { + document.getElementById('addressTabTransactions').style.display = 'block'; + document.getElementById('addrTabTxs').classList.add('active'); + } else if (tabName === 'tokens') { + document.getElementById('addressTabTokens').style.display = 'block'; + document.getElementById('addrTabTokens').classList.add('active'); + loadAddressTokenBalances(addr); + } else if (tabName === 'internal') { + document.getElementById('addressTabInternal').style.display = 'block'; + document.getElementById('addrTabInternal').classList.add('active'); + loadAddressInternalTxns(addr); + } else if (tabName === 'nfts') { + document.getElementById('addressTabNfts').style.display = 'block'; + document.getElementById('addrTabNfts').classList.add('active'); + loadAddressNftInventory(addr); + } else if (tabName === 'contract') { + var contractPanel = document.getElementById('addressTabContract'); + var contractTab = document.getElementById('addrTabContract'); + if (contractPanel && contractTab) { + contractPanel.style.display = 'block'; + contractTab.classList.add('active'); + loadAddressContractInfo(addr); + } + } + } + window.switchAddressTab = switchAddressTab; + + async function loadAddressTokenBalances(addr) { + const el = document.getElementById('addressTokenBalances'); + if (!el || el.dataset.loaded === '1') return; + try { + const r = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/addresses/' + addr + '/token-balances').catch(function() { return { items: [] }; }); + const r2 = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/addresses/' + addr + '/token_balances').catch(function() { return { items: [] }; }); + const items = (r.items || r).length ? (r.items || r) : (r2.items || r2); + el.dataset.loaded = '1'; + if (!items || items.length === 0) { + el.innerHTML = '

No token balances

'; + return; + } + let tbl = ''; + 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 += ''; + }); + tbl += '
TokenContractBalanceType
' + escapeHtml(symbol) + '' + escapeHtml(shortenHash(contract)) + '' + escapeHtml(displayBalance) + '' + escapeHtml(type) + '
'; + el.innerHTML = tbl; + } catch (e) { + el.innerHTML = '

Failed to load token balances

'; + } + } + + async function loadAddressNftInventory(addr) { + const el = document.getElementById('addressNftInventory'); + if (!el || el.dataset.loaded === '1') return; + try { + const r = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/addresses/' + addr + '/token-balances').catch(function() { return { items: [] }; }); + const r2 = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/addresses/' + addr + '/nft-inventory').catch(function() { return { items: [] }; }); + const r3 = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/addresses/' + addr + '/nft_tokens').catch(function() { return { items: [] }; }); + var items = (r2.items || r2).length ? (r2.items || r2) : (r3.items || r3); + if (!items || items.length === 0) { + var allBalances = (r.items || r) || []; + items = Array.isArray(allBalances) ? allBalances.filter(function(b) { + var t = (b.token || b).type || (b.token_type || ''); + return t === 'ERC-721' || t === 'ERC-1155' || String(t).toLowerCase().indexOf('nft') !== -1; + }) : []; + } + el.dataset.loaded = '1'; + if (!items || items.length === 0) { + el.innerHTML = '

No NFT tokens

'; + return; + } + var tbl = ''; + items.forEach(function(b) { + var token = b.token || b; + var contract = token.address?.hash || token.address || b.token_contract_address_hash || b.contract_address_hash || 'N/A'; + var tokenId = b.token_id != null ? b.token_id : (b.tokenId != null ? b.tokenId : (b.id != null ? b.id : '-')); + var name = token.name || token.symbol || '-'; + var balance = b.value != null ? b.value : (b.balance != null ? b.balance : '1'); + tbl += ''; + tbl += ''; + tbl += ''; + }); + tbl += '
ContractToken IDName / SymbolBalance
' + escapeHtml(shortenHash(contract)) + '' + (tokenId !== '-' ? '' + escapeHtml(String(tokenId)) + '' : '-') + '' + escapeHtml(name) + '' + escapeHtml(String(balance)) + '
'; + el.innerHTML = tbl; + } catch (e) { + el.innerHTML = '

Failed to load NFT inventory

'; + } + } + + 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 = '

No internal transactions

'; + return; + } + let tbl = ''; + 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 += ''; + }); + tbl += '
BlockFromToValueTx Hash
' + escapeHtml(block) + '' + escapeHtml(shortenHash(from)) + '' + escapeHtml(shortenHash(to)) + '' + escapeHtml(val) + ' ETH' + (txHash !== '-' ? escapeHtml(shortenHash(txHash)) : '-') + '
'; + el.innerHTML = tbl; + } catch (e) { + el.innerHTML = '

Failed to load internal transactions

'; + } + } + + 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 = '

Contract source not indexed. Verify on Blockscout

'; + 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 = '

Verification: ' + (data.is_verified ? 'Verified' : 'Unverified') + '

'; + html += '

ABI Download

'; + html += '
' + escapeHtml(abiStr) + '
'; + html += '

Bytecode

'; + html += '
' + escapeHtml(String(bytecode).substring(0, 500)) + (String(bytecode).length > 500 ? '...' : '') + '
'; + var viewFns = (Array.isArray(abi) ? abi : []).filter(function(x) { return x.type === 'function' && (x.stateMutability === 'view' || x.stateMutability === 'pure' || (x.constant === true)); }); + if (viewFns.length > 0) { + html += '

Read contract

Call view/pure functions (requires ethers.js).

'; + html += '
'; + html += '
'; + } + var writeFns = (Array.isArray(abi) ? abi : []).filter(function(x) { return x.type === 'function' && x.stateMutability !== 'view' && x.stateMutability !== 'pure' && !x.constant; }); + if (writeFns.length > 0) { + html += '

Write contract

Connect wallet to send transactions.

'; + html += '
'; + html += ''; + html += '
'; + } + html += '

Read / Write contract on Blockscout

'; + el.innerHTML = html; + if (viewFns.length > 0) { + (function setupReadContract(contractAddr, abiJson, viewFunctions) { + var selectEl = document.getElementById('readContractSelect'); + var inputsEl = document.getElementById('readContractInputs'); + var resultEl = document.getElementById('readContractResult'); + var btnEl = document.getElementById('readContractBtn'); + function renderInputs() { + var name = selectEl && selectEl.value; + var fn = viewFunctions.find(function(f) { return f.name === name; }); + if (!inputsEl || !fn) return; + var inputs = fn.inputs || []; + if (inputs.length === 0) { inputsEl.innerHTML = ''; return; } + var h = '
'; + inputs.forEach(function(inp, i) { + h += ''; + }); + h += '
'; + inputsEl.innerHTML = h; + } + if (selectEl) selectEl.addEventListener('change', renderInputs); + renderInputs(); + if (btnEl) btnEl.addEventListener('click', function() { + if (typeof ethers === 'undefined') { showToast('Ethers.js not loaded. Refresh the page.', 'error'); return; } + var name = selectEl && selectEl.value; + var fn = viewFunctions.find(function(f) { return f.name === name; }); + if (!fn || !resultEl) return; + var inputs = fn.inputs || []; + var args = []; + for (var i = 0; i < inputs.length; i++) { + var inp = inputs[i]; + var val = document.getElementById('readArg' + i) && document.getElementById('readArg' + i).value; + if (val === undefined || val === '') val = ''; + var t = (inp.type || '').toLowerCase(); + if (t.indexOf('uint') === 0 || t === 'int256') args.push(val ? (val.trim() ? BigInt(val) : 0) : 0); + else if (t === 'bool') args.push(val === 'true' || val === '1'); + else if (t.indexOf('address') === 0) args.push(val && val.trim() ? val.trim() : '0x0000000000000000000000000000000000000000'); + else args.push(val); + } + resultEl.style.display = 'block'; + resultEl.textContent = 'Calling...'; + var provider = new ethers.providers.JsonRpcProvider(RPC_URL); + var contract = new ethers.Contract(contractAddr, abiJson, provider); + contract[name].apply(contract, args).then(function(res) { + if (Array.isArray(res)) resultEl.textContent = JSON.stringify(res, null, 2); + else if (res != null && typeof res.toString === 'function') resultEl.textContent = res.toString(); + else resultEl.textContent = JSON.stringify(res, null, 2); + }).catch(function(err) { + resultEl.textContent = 'Error: ' + (err.message || String(err)); + }); + }); + })(addr, abi, viewFns); + } + if (writeFns.length > 0) { + (function setupWriteContract(contractAddr, abiJson, writeFunctions) { + var selectEl = document.getElementById('writeContractSelect'); + var inputsEl = document.getElementById('writeContractInputs'); + var valueRow = document.getElementById('writeContractValueRow'); + var valueEl = document.getElementById('writeContractValue'); + var resultEl = document.getElementById('writeContractResult'); + var btnEl = document.getElementById('writeContractBtn'); + function renderWriteInputs() { + var name = selectEl && selectEl.value; + var opt = selectEl && selectEl.options[selectEl.selectedIndex]; + var payable = opt && opt.getAttribute('data-payable') === 'true'; + if (valueRow) valueRow.style.display = payable ? 'block' : 'none'; + var fn = writeFunctions.find(function(f) { return f.name === name; }); + if (!inputsEl || !fn) return; + var inputs = fn.inputs || []; + var h = '
'; + inputs.forEach(function(inp, i) { + h += ''; + }); + h += '
'; + inputsEl.innerHTML = h; + } + if (selectEl) selectEl.addEventListener('change', renderWriteInputs); + renderWriteInputs(); + if (btnEl) btnEl.addEventListener('click', function() { + if (typeof ethers === 'undefined') { showToast('Ethers.js not loaded. Refresh the page.', 'error'); return; } + if (!window.ethereum) { showToast('Connect MetaMask to write.', 'error'); return; } + var name = selectEl && selectEl.value; + var fn = writeFunctions.find(function(f) { return f.name === name; }); + if (!fn || !resultEl) return; + var inputs = fn.inputs || []; + var args = []; + for (var i = 0; i < inputs.length; i++) { + var inp = inputs[i]; + var val = document.getElementById('writeArg' + i) && document.getElementById('writeArg' + i).value; + if (val === undefined || val === '') val = ''; + var t = (inp.type || '').toLowerCase(); + if (t.indexOf('uint') === 0 || t === 'int256') args.push(val ? (val.trim() ? BigInt(val) : 0) : 0); + else if (t === 'bool') args.push(val === 'true' || val === '1'); + else if (t.indexOf('address') === 0) args.push(val && val.trim() ? val.trim() : '0x0000000000000000000000000000000000000000'); + else args.push(val); + } + var valueWei = '0'; + if (fn.stateMutability === 'payable' && valueEl && valueEl.value) { + try { valueWei = ethers.utils.parseEther(valueEl.value.trim() || '0').toString(); } catch (e) { showToast('Invalid ETH value', 'error'); return; } + } + resultEl.style.display = 'block'; + resultEl.textContent = 'Confirm in wallet...'; + var provider = new ethers.providers.Web3Provider(window.ethereum); + provider.send('eth_requestAccounts', []).then(function() { + var signer = provider.getSigner(); + var contract = new ethers.Contract(contractAddr, abiJson, signer); + var overrides = valueWei !== '0' ? { value: valueWei } : {}; + return contract[name].apply(contract, args.concat([overrides])); + }).then(function(tx) { + resultEl.textContent = 'Tx hash: ' + tx.hash + '\nWaiting for confirmation...'; + return tx.wait(); + }).then(function(receipt) { + resultEl.textContent = 'Success. Block: ' + receipt.blockNumber + ', Tx: ' + receipt.transactionHash; + showToast('Transaction confirmed', 'success'); + }).catch(function(err) { + resultEl.textContent = 'Error: ' + (err.message || String(err)); + showToast(err.message || 'Transaction failed', 'error'); + }); + }); + })(addr, abi, writeFns); + } + } catch (e) { + el.innerHTML = '

Failed to load contract info

'; + } + } + + 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 = ''; + txs.data.forEach(function(tx) { + txHtml += ''; + }); + txHtml += '
HashBlockFromToValue
' + escapeHtml(shortenHash(tx.hash)) + '' + escapeHtml(String(tx.block_number)) + '' + formatAddressWithLabel(tx.from) + '' + (tx.to ? formatAddressWithLabel(tx.to) : 'N/A') + '' + escapeHtml(formatEther(tx.value || '0')) + ' ETH
'; + txContainer.innerHTML = txHtml; + } else { + txContainer.innerHTML = '

No transactions found

'; + } + } + } catch (e) { + const txContainer = document.getElementById('addressTransactions'); + if (txContainer) txContainer.innerHTML = '

Failed to load transactions

'; + } + } else { + container.innerHTML = '
Address not found
'; + } + } catch (error) { + container.innerHTML = '
Failed to load address: ' + escapeHtml(error.message) + '
'; + } + } + window._showAddressDetail = showAddressDetail; + window.showAddressDetail = showAddressDetail; + + async function showTokenDetail(tokenAddress) { + if (!/^0x[a-fA-F0-9]{40}$/.test(tokenAddress)) return; + currentDetailKey = 'token:' + tokenAddress.toLowerCase(); + showView('tokenDetail'); + updatePath('/token/' + tokenAddress); + var container = document.getElementById('tokenDetail'); + updateBreadcrumb('token', tokenAddress); + container.innerHTML = createSkeletonLoader('detail'); + + try { + var urls = [BLOCKSCOUT_API + '/v2/tokens/' + tokenAddress, BLOCKSCOUT_API + '/v2/token/' + tokenAddress]; + var data = null; + for (var i = 0; i < urls.length; i++) { + try { + var r = await fetchAPIWithRetry(urls[i]); + if (r && (r.symbol || r.name || r.total_supply != null)) { data = r; break; } + } catch (e) {} + } + if (!data) { + container.innerHTML = '

Token not found or not indexed.

View as address

'; + 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 = '
Contract
' + escapeHtml(tokenAddress) + '
'; + html += '
Name
' + escapeHtml(name) + '
'; + html += '
Symbol
' + escapeHtml(symbol) + '
'; + html += '
Decimals
' + decimals + '
'; + html += '
Total Supply
' + supplyNum.toLocaleString(undefined, { maximumFractionDigits: 6 }) + '
'; + html += '
Holders
' + (holders !== '-' ? formatNumber(holders) : '-') + '
'; + html += '

Recent Transfers

'; + if (transfers.length === 0) { + html += '

No transfers

'; + } else { + html += ''; + 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 += ''; + }); + html += '
FromToValueTx
' + escapeHtml(shortenHash(from)) + '' + escapeHtml(shortenHash(to)) + '' + escapeHtml(v.toLocaleString(undefined, { maximumFractionDigits: 6 })) + '' + (txHash ? escapeHtml(shortenHash(txHash)) : '-') + '
'; + } + html += '
'; + container.innerHTML = html; + } catch (err) { + container.innerHTML = '
Failed to load token: ' + escapeHtml(err.message || 'Unknown') + '
'; + } + } + window.showTokenDetail = showTokenDetail; + + async function showNftDetail(contractAddress, tokenId) { + if (!/^0x[a-fA-F0-9]{40}$/.test(contractAddress)) return; + currentDetailKey = 'nft:' + contractAddress.toLowerCase() + ':' + tokenId; + showView('nftDetail'); + updatePath('/nft/' + contractAddress + '/' + tokenId); + var container = document.getElementById('nftDetail'); + updateBreadcrumb('nft', contractAddress, tokenId); + container.innerHTML = createSkeletonLoader('detail'); + + try { + var urls = [BLOCKSCOUT_API + '/v2/tokens/' + contractAddress + '/nft/' + tokenId, BLOCKSCOUT_API + '/v2/nft/' + contractAddress + '/' + tokenId]; + var data = null; + for (var i = 0; i < urls.length; i++) { + try { + var r = await fetchAPIWithRetry(urls[i]); + if (r) { data = r; break; } + } catch (e) {} + } + var html = '
Contract
' + escapeHtml(contractAddress) + '
'; + html += '
Token ID
' + escapeHtml(String(tokenId)) + '
'; + if (data) { + if (data.metadata && data.metadata.image) { + html += '
Image
NFT
'; + } + if (data.name) html += '
Name
' + escapeHtml(data.name) + '
'; + if (data.description) html += '
Description
' + escapeHtml(data.description) + '
'; + if (data.owner) { var ownerAddr = (data.owner.hash || data.owner); html += '
Owner
' + escapeHtml(ownerAddr) + '
'; } + if (data.metadata && data.metadata.attributes && Array.isArray(data.metadata.attributes)) { + html += '
Traits
'; + data.metadata.attributes.forEach(function(attr) { + var traitType = (attr.trait_type || attr.traitType || ''); var val = (attr.value != null ? attr.value : ''); + if (traitType || val) html += '' + escapeHtml(traitType) + ': ' + escapeHtml(String(val)) + ''; + }); + html += '
'; + } + } + html += '

View on Blockscout

'; + container.innerHTML = html; + } catch (err) { + container.innerHTML = '
Failed to load NFT: ' + escapeHtml(err.message || 'Unknown') + '
'; + } + } + window.showNftDetail = showNftDetail; + + function showSearchResultsList(items, query) { + showView('searchResults'); + var container = document.getElementById('searchResultsContent'); + if (!container) return; + var html = '

Found ' + items.length + ' result(s) for "' + escapeHtml(query) + '". Click a row to open.

'; + html += ''; + items.forEach(function(item) { + var type = (item.type || item.address_type || '').toLowerCase(); + var label = item.name || item.symbol || item.address_hash || item.hash || item.tx_hash || (item.block_number != null ? 'Block #' + item.block_number : '') || '-'; + var addr, txHash, blockNum, tokenAddr; + if (item.token_address || item.token_contract_address_hash) { + tokenAddr = item.token_address || item.token_contract_address_hash; + if (/^0x[a-f0-9]{40}$/i.test(tokenAddr)) { + html += ''; + return; + } + } + if (item.address_hash || item.hash) { + addr = item.address_hash || item.hash; + if (/^0x[a-f0-9]{40}$/i.test(addr)) { + html += ''; + return; + } + } + if (item.tx_hash || (item.hash && item.hash.length === 66)) { + txHash = item.tx_hash || item.hash; + if (/^0x[a-f0-9]{64}$/i.test(txHash)) { + html += ''; + return; + } + } + if (item.block_number != null) { + blockNum = String(item.block_number); + html += ''; + return; + } + html += ''; + }); + html += '
TypeValue
Token' + escapeHtml(shortenHash(tokenAddr)) + ' ' + (item.name || item.symbol ? ' (' + escapeHtml(item.name || item.symbol) + ')' : '') + '
Address' + escapeHtml(shortenHash(addr)) + '
Transaction' + escapeHtml(shortenHash(txHash)) + '
Block#' + escapeHtml(blockNum) + '
' + escapeHtml(type || 'Unknown') + '' + escapeHtml(String(label).substring(0, 80)) + '
'; + container.innerHTML = html; + } + window.showSearchResultsList = showSearchResultsList; + + 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) { + showSearchResultsList(searchResults.items, query); + return; + } + if (/^0x[a-f0-9]{8,64}$/i.test(normalizedQuery)) { + try { + var txResp = await fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/transactions/' + normalizedQuery); + if (txResp && (txResp.hash || txResp.tx_hash)) { + var fullHash = txResp.hash || txResp.tx_hash; + await showTransactionDetail(fullHash); + return; + } + } catch (e) {} + showToast('No unique result for partial hash. Use at least 0x + 8 hex characters, or full tx hash (0x + 64 hex).', 'info'); + return; + } + } + + showToast('Invalid search. Try address (0x...40 hex), tx hash (0x...64 hex or 0x+8 hex), block number, or token/contract name.', 'error'); + } catch (error) { + console.error('Search error:', error); + showToast('Failed to load search results: ' + (error.message || 'Unknown error'), 'error'); + } + } + window.handleSearch = handleSearch; + + function getTimeAgo(date) { + if (!date || !(date instanceof Date) || isNaN(date.getTime())) { + return 'N/A'; + } + + const now = new Date(); + const diffMs = now - date; + const diffSecs = Math.floor(diffMs / 1000); + const diffMins = Math.floor(diffSecs / 60); + const diffHours = Math.floor(diffMins / 60); + const diffDays = Math.floor(diffHours / 24); + + if (diffSecs < 60) { + return `${diffSecs}s ago`; + } else if (diffMins < 60) { + return `${diffMins}m ago`; + } else if (diffHours < 24) { + return `${diffHours}h ago`; + } else if (diffDays < 7) { + return `${diffDays}d ago`; + } else { + return date.toLocaleDateString(); + } + } + + function formatNumber(num) { + return parseInt(num || 0).toLocaleString(); + } + + function shortenHash(hash, length = 10) { + if (!hash) return 'N/A'; + // Convert to string if it's not already + const hashStr = String(hash); + if (hashStr.length <= length * 2 + 2) return hashStr; + return hashStr.substring(0, length + 2) + '...' + hashStr.substring(hashStr.length - length); + } + + function formatEther(wei, unit = 'ether') { + if (typeof wei === 'string' && wei.startsWith('0x')) { + wei = BigInt(wei); + } + const weiNum = typeof wei === 'bigint' ? Number(wei) : parseFloat(wei); + const ether = weiNum / Math.pow(10, unit === 'gwei' ? 9 : 18); + return ether.toFixed(6).replace(/\.?0+$/, ''); + } + + // 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(); + }); + initNavDropdowns(); + } + }); diff --git a/frontend/public/index.html b/frontend/public/index.html index 5cc4c95..d420696 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -6,7 +6,7 @@ - + SolaceScanScout | The Defi Oracle Meta Explorer | d-bis.org @@ -179,15 +179,67 @@ } .nav-links { display: flex; - gap: 2rem; + gap: 0.5rem; list-style: none; + align-items: center; } - .nav-links a { + .nav-links > li { position: relative; } + .nav-links a, .nav-dropdown-trigger { color: white; text-decoration: none; transition: opacity 0.2s; + display: inline-flex; + align-items: center; + gap: 0.35rem; } - .nav-links a:hover { opacity: 0.8; } + .nav-links a:hover, .nav-dropdown-trigger:hover { opacity: 0.9; } + .nav-dropdown-trigger { + background: none; + border: none; + cursor: pointer; + font: inherit; + padding: 0.5rem 0.25rem; + } + .nav-dropdown-trigger i.fa-chevron-down { + font-size: 0.7rem; + transition: transform 0.2s; + } + .nav-dropdown.open .nav-dropdown-trigger i.fa-chevron-down { transform: rotate(180deg); } + .nav-dropdown-menu { + position: absolute; + top: 100%; + left: 0; + min-width: 200px; + background: rgba(30, 41, 59, 0.98); + border-radius: 8px; + box-shadow: 0 10px 40px rgba(0,0,0,0.25); + padding: 0.5rem 0; + list-style: none; + opacity: 0; + visibility: hidden; + transform: translateY(-6px); + transition: opacity 0.2s, transform 0.2s, visibility 0.2s; + z-index: 1001; + margin-top: 0.25rem; + } + .nav-dropdown.open .nav-dropdown-menu { + opacity: 1; + visibility: visible; + transform: translateY(0); + } + .nav-dropdown-menu a { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.6rem 1rem; + color: rgba(255,255,255,0.95); + white-space: nowrap; + } + .nav-dropdown-menu a:hover { + background: rgba(255,255,255,0.1); + opacity: 1; + } + .nav-dropdown-menu li { margin: 0; } .search-box { flex: 1; max-width: 600px; @@ -725,15 +777,25 @@ .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 { display: none; flex-direction: column; align-items: stretch; position: absolute; top: 100%; left: 0; right: 0; background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); padding: 1rem; gap: 0; 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; } + .nav-dropdown { position: static; } + .nav-dropdown-menu { position: static; opacity: 1; visibility: visible; transform: none; background: rgba(0,0,0,0.2); border-radius: 6px; margin: 0.25rem 0 0 0.5rem; padding: 0.25rem 0; max-height: 0; overflow: hidden; transition: max-height 0.2s; } + .nav-dropdown.open .nav-dropdown-menu { max-height: 400px; } + .nav-dropdown-trigger { width: 100%; justify-content: space-between; padding: 0.6rem 0.5rem; } } @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; } } + #gasNetworkContent { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; } + @media (max-width: 600px) { + #gasNetworkContent { grid-template-columns: 1fr; } + } + .btn-copy { background: none; border: none; cursor: pointer; padding: 0.25rem; margin-left: 0.35rem; color: var(--text-light); vertical-align: middle; } + .btn-copy:hover { color: var(--primary); } @@ -756,16 +818,38 @@
+
@@ -785,6 +869,26 @@
+
+
+

Gas & Network

+ +
+
+
+
Current base fee
+
+
TPS:
+
Block time:
+
Failed (recent):
+
+
+
Gas history (last 10 blocks)
+
+
+
+
+

Latest Blocks

@@ -952,7 +1056,7 @@

Cross-Chain Bridging

Both WETH9 and WETH10 can be bridged to other chains using the CCIP bridge contracts:

    -
  • WETH9 Bridge: 0x89dd12025bfCD38A168455A44B400e913ED33BE2
  • +
  • WETH9 Bridge: 0x971cD9D156f193df8051E48043C476e53ECd4693
  • WETH10 Bridge: 0xe0E93247376aa097dB308B92e6Ba36bA015535D0
@@ -1053,3126 +1157,67 @@
+ +
+
+
+ +

Search results

+
+
+
+
+ +
+
+
+ +

Tokens

+
+
+
Loading...
+
+
+
+ +
+ +
+
+ +

Watchlist

+
+
+

Add addresses from their detail page to track them here.

+
+
+
+ +
+
+
+ +

Analytics Dashboard

+
+
+

Analytics dashboard (Track 3). Network stats, flow tracking, and bridge analytics coming soon.

+
+
+
+ +
+
+
+ +

Operator Panel

+
+
+

Operator panel (Track 4). Configuration and operational controls coming soon.

+
+
+
- + diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 90d3343..c14664c 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -1,6 +1,6 @@ import type { Metadata } from 'next' import { Inter } from 'next/font/google' -import Link from 'next/link' +import Navbar from '@/components/common/Navbar' import './globals.css' const inter = Inter({ subsets: ['latin'] }) @@ -18,34 +18,7 @@ export default function RootLayout({ return ( - +
{children}
diff --git a/frontend/src/components/common/Navbar.tsx b/frontend/src/components/common/Navbar.tsx new file mode 100644 index 0000000..40f3f64 --- /dev/null +++ b/frontend/src/components/common/Navbar.tsx @@ -0,0 +1,166 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { useState } from 'react' + +const navLink = 'text-gray-700 dark:text-gray-300 hover:text-primary-600 dark:hover:text-primary-400 transition-colors' +const navLinkActive = 'text-primary-600 dark:text-primary-400 font-medium' + +function NavDropdown({ + label, + icon, + children, +}: { + label: string + icon: React.ReactNode + children: React.ReactNode +}) { + const [open, setOpen] = useState(false) + return ( +
setOpen(true)} + onMouseLeave={() => setOpen(false)} + > + + {open && ( +
    + {children} +
+ )} +
+ ) +} + +function DropdownItem({ + href, + icon, + children, + external, +}: { + href: string + icon?: React.ReactNode + children: React.ReactNode + external?: boolean +}) { + const className = `flex items-center gap-2 px-4 py-2.5 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-primary-600 dark:hover:text-primary-400 ${navLink}` + if (external) { + return ( +
  • + + {icon} + {children} + +
  • + ) + } + return ( +
  • + + {icon} + {children} + +
  • + ) +} + +export default function Navbar() { + const pathname = usePathname() ?? '' + const [mobileMenuOpen, setMobileMenuOpen] = useState(false) + const [exploreOpen, setExploreOpen] = useState(false) + const [toolsOpen, setToolsOpen] = useState(false) + + return ( + + ) +}