diff --git a/.gitignore b/.gitignore index a68d9dc..e965055 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,10 @@ backend/bin/ backend/api/rest/cmd/api-server backend/cmd +# Python +__pycache__/ +*.py[cod] + # Tooling / scratch directories out/ cache/ diff --git a/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json b/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json index e6c1198..ffa6b0e 100644 --- a/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json +++ b/backend/api/rest/config/metamask/DUAL_CHAIN_NETWORKS.json @@ -3,10 +3,10 @@ "version": {"major": 1, "minor": 2, "patch": 0}, "defaultChainId": 138, "explorerUrl": "https://explorer.d-bis.org", - "tokenListUrl": "https://explorer.d-bis.org/api/config/token-list", + "tokenListUrl": "https://explorer.d-bis.org/api/v1/report/token-list?chainId=138", "generatedBy": "DBIS Explorer", "chains": [ - {"chainId":"0x8a","chainIdDecimal":138,"chainName":"DeFi Oracle Meta Mainnet","shortName":"dbis","rpcUrls":["https://rpc-http-pub.d-bis.org","https://rpc.d-bis.org","https://rpc2.d-bis.org","https://rpc.defi-oracle.io"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://explorer.d-bis.org","https://blockscout.defi-oracle.io"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"],"infoURL":"https://explorer.d-bis.org","explorerApiUrl":"https://explorer.d-bis.org/api/v2","testnet":false}, + {"chainId":"0x8a","chainIdDecimal":138,"chainName":"DeFi Oracle Meta Mainnet","shortName":"dbis","rpcUrls":["https://rpc-http-pub.d-bis.org","https://rpc.d-bis.org","https://rpc2.d-bis.org","https://rpc.defi-oracle.io"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://explorer.d-bis.org","https://blockscout.defi-oracle.io"],"iconUrls":["https://explorer.d-bis.org/token-icons/chain-138.png","https://explorer.d-bis.org/api/v1/report/logo/chain-138","https://explorer.d-bis.org/favicon.ico"],"infoURL":"https://explorer.d-bis.org","explorerApiUrl":"https://explorer.d-bis.org/api/v2","testnet":false}, {"chainId":"0x1","chainIdDecimal":1,"chainName":"Ethereum Mainnet","shortName":"eth","rpcUrls":["https://eth.llamarpc.com","https://rpc.ankr.com/eth","https://ethereum.publicnode.com","https://1rpc.io/eth"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://etherscan.io"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"],"infoURL":"https://ethereum.org","testnet":false}, {"chainId":"0x9f2c4","chainIdDecimal":651940,"chainName":"ALL Mainnet","shortName":"all","rpcUrls":["https://mainnet-rpc.alltra.global"],"nativeCurrency":{"name":"Ether","symbol":"ETH","decimals":18},"blockExplorerUrls":["https://alltra.global"],"iconUrls":["https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png"],"infoURL":"https://alltra.global","testnet":false}, {"chainId":"0x19","chainIdDecimal":25,"chainName":"Cronos Mainnet","rpcUrls":["https://evm.cronos.org","https://cronos-rpc.publicnode.com"],"nativeCurrency":{"name":"CRO","symbol":"CRO","decimals":18},"blockExplorerUrls":["https://cronos.org/explorer"],"iconUrls":["https://ipfs.io/ipfs/Qma3FKtLce9MjgJgWbtyCxBiPjJ6xi8jGWUSKNS5Jc2ong"]}, diff --git a/backend/api/rest/config/metamask/GRU_V2_PUBLIC_DEPLOYMENT_STATUS.json b/backend/api/rest/config/metamask/GRU_V2_PUBLIC_DEPLOYMENT_STATUS.json index 9eafa26..1def54f 100644 --- a/backend/api/rest/config/metamask/GRU_V2_PUBLIC_DEPLOYMENT_STATUS.json +++ b/backend/api/rest/config/metamask/GRU_V2_PUBLIC_DEPLOYMENT_STATUS.json @@ -129,7 +129,7 @@ "coveredSymbols": 10, "missingSymbols": [] }, - "note": "The public EVM cW token mesh is complete on the currently loaded 10-chain set, but Wemix remains a desired target without a cW suite in deployment-status.json." + "note": "The public EVM cW token mesh is aligned to the nine-chain promoted surface (Cronos excluded from that count); Wemix remains a desired target without a cW suite in deployment-status.json." }, "transport": { "liveTransportAssets": [ diff --git a/backend/config/metamask/DUAL_CHAIN_NETWORKS.json b/backend/config/metamask/DUAL_CHAIN_NETWORKS.json index 73ee120..f8dc9b0 100644 --- a/backend/config/metamask/DUAL_CHAIN_NETWORKS.json +++ b/backend/config/metamask/DUAL_CHAIN_NETWORKS.json @@ -25,7 +25,9 @@ "https://explorer.d-bis.org" ], "iconUrls": [ - "https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png" + "https://explorer.d-bis.org/token-icons/chain-138.png", + "https://explorer.d-bis.org/api/v1/report/logo/chain-138", + "https://explorer.d-bis.org/favicon.ico" ] }, { @@ -90,4 +92,4 @@ ] } ] -} \ No newline at end of file +} diff --git a/frontend/FRONTEND_TASKS_AND_REVIEW.md b/frontend/FRONTEND_TASKS_AND_REVIEW.md index 2315cf1..2933c3a 100644 --- a/frontend/FRONTEND_TASKS_AND_REVIEW.md +++ b/frontend/FRONTEND_TASKS_AND_REVIEW.md @@ -97,7 +97,7 @@ - **Wallet status (1639, 1722)** – `statusEl.innerHTML` uses `shortenHash(userAddress)`. If `userAddress` were ever from an untrusted source, it should be escaped. **Action:** Use `escapeHtml(shortenHash(userAddress))` for consistency (in **H1**). - **loadGasAndNetworkStats (2509)** – `el.innerHTML` uses `gasGwei`, `blockTimeSec`, `tps`. These are from API; escaping is low risk but recommended for defense in depth. **Action:** Escape these values (in **H1** or small follow-up). - **Token list: `#/token/' + contract`** – The `contract` in `href="#/token/' + contract + '"` can break the attribute if it contains a quote. **Action:** Encode or validate; include in **H2** (safe href/attributes). -- **External link (3800)** – `'https://explorer.d-bis.org/address/' + addr + '/contract'` – `addr` should be validated or encoded so the URL cannot be malformed. **Action:** Use `encodeURIComponent(addr)` for the path segment (in **H2**). +- **External link (3800)** – `'https://explorer.d-bis.org/addresses/' + addr + '/contract'` – `addr` should be validated or encoded so the URL cannot be malformed. **Action:** Use `encodeURIComponent(addr)` for the path segment (in **H2**). ### 2.3 SPA: onclick and attribute injection diff --git a/frontend/public/explorer-spa.js b/frontend/public/explorer-spa.js index bde3f3e..e9e2e17 100644 --- a/frontend/public/explorer-spa.js +++ b/frontend/public/explorer-spa.js @@ -332,9 +332,11 @@ setTimeout(function() { input.focus(); input.select(); + if (typeof focusTrapStart === 'function') focusTrapStart(modal); }, 0); } function closeSmartSearchModal() { + if (typeof focusTrapEnd === 'function') focusTrapEnd(); var modal = document.getElementById('smartSearchModal'); if (!modal) return; modal.style.display = 'none'; @@ -964,6 +966,138 @@ 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; } + + var INSTITUTION_PREFS_KEY = 'explorer_inst_prefs_v1'; + function getInstitutionPrefs() { + try { + var j = localStorage.getItem(INSTITUTION_PREFS_KEY); + var o = j ? JSON.parse(j) : {}; + return { + dateFormat: o.dateFormat === 'local' ? 'local' : 'iso', + gasUnit: o.gasUnit === 'eth' ? 'eth' : 'gwei', + externalLinks: o.externalLinks === 'blockscout' ? 'blockscout' : 'spa', + addressRows: o.addressRows === 'compact' ? 'compact' : 'rich' + }; + } catch (e) { + return { dateFormat: 'iso', gasUnit: 'gwei', externalLinks: 'spa', addressRows: 'rich' }; + } + } + function setInstitutionPrefs(patch) { + var cur = getInstitutionPrefs(); + Object.assign(cur, patch || {}); + try { localStorage.setItem(INSTITUTION_PREFS_KEY, JSON.stringify(cur)); } catch (e) {} + applyInstitutionPrefsToDom(); + if (typeof syncContractVerifyBlockscoutLink === 'function') syncContractVerifyBlockscoutLink(); + return cur; + } + function applyInstitutionPrefsToDom() { + var p = getInstitutionPrefs(); + try { + document.body.classList.toggle('explorer-compact-table-rows', p.addressRows === 'compact'); + } catch (e) {} + } + function formatInstitutionTimestamp(isoOrMs) { + if (isoOrMs == null || isoOrMs === '') return '—'; + var d = typeof isoOrMs === 'number' ? new Date(isoOrMs) : new Date(isoOrMs); + if (!Number.isFinite(d.getTime())) return '—'; + var p = getInstitutionPrefs(); + if (p.dateFormat === 'local') return d.toLocaleString(); + return d.toISOString(); + } + var SAVED_VIEWS_KEY = 'explorer_saved_views_v1'; + function getSavedViews() { + try { var j = localStorage.getItem(SAVED_VIEWS_KEY); var a = j ? JSON.parse(j) : []; return Array.isArray(a) ? a : []; } catch (e) { return []; } + } + function saveSavedViews(arr) { try { localStorage.setItem(SAVED_VIEWS_KEY, JSON.stringify((arr || []).slice(0, 40))); } catch (e) {} } + var FILTER_PRESETS_KEY = 'explorer_filter_presets_v1'; + function getFilterPresets() { try { var j = localStorage.getItem(FILTER_PRESETS_KEY); return j ? JSON.parse(j) : {}; } catch (e) { return {}; } } + function setFilterPresets(m) { try { localStorage.setItem(FILTER_PRESETS_KEY, JSON.stringify(m || {})); } catch (e) {} } + var NOTIF_SETTINGS_KEY = 'explorer_notif_settings_v1'; + function getNotifSettings() { + try { + var j = localStorage.getItem(NOTIF_SETTINGS_KEY); + var o = j ? JSON.parse(j) : {}; + return { + enabled: !!o.enabled, + intervalMin: Math.max(5, Math.min(1440, parseInt(o.intervalMin, 10) || 60)) + }; + } catch (e) { return { enabled: false, intervalMin: 60 }; } + } + function setNotifSettings(patch) { + var cur = getNotifSettings(); + Object.assign(cur, patch || {}); + try { localStorage.setItem(NOTIF_SETTINGS_KEY, JSON.stringify(cur)); } catch (e) {} + return cur; + } + var NOTIF_SNAP_KEY = 'explorer_notif_snap_v1'; + function getNotifSnap() { try { var j = localStorage.getItem(NOTIF_SNAP_KEY); return j ? JSON.parse(j) : {}; } catch (e) { return {}; } } + function setNotifSnap(m) { try { localStorage.setItem(NOTIF_SNAP_KEY, JSON.stringify(m || {})); } catch (e) {} } + var REPORT_SCHED_KEY = 'explorer_report_schedules_v1'; + function getReportSchedules() { try { var j = localStorage.getItem(REPORT_SCHED_KEY); var a = j ? JSON.parse(j) : []; return Array.isArray(a) ? a : []; } catch (e) { return []; } } + function setReportSchedules(a) { try { localStorage.setItem(REPORT_SCHED_KEY, JSON.stringify((a || []).slice(0, 20))); } catch (e) {} } + + function decodeExplorerJwtPayload(token) { + if (!token || typeof token !== 'string') return null; + var parts = token.split('.'); + if (parts.length < 2) return null; + try { + var b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/'); + var pad = b64.length % 4 ? '='.repeat(4 - (b64.length % 4)) : ''; + var json = atob(b64 + pad); + return JSON.parse(json); + } catch (e) { return null; } + } + + var _focusTrapState = null; + function focusTrapStart(rootEl) { + focusTrapEnd(); + if (!rootEl) return; + function selectable(el) { + if (!el || el.disabled || el.getAttribute('aria-hidden') === 'true') return false; + var ti = el.getAttribute('tabindex'); + if (ti === '-1') return false; + return el.tabIndex >= 0 || el.matches('a[href],button:not([disabled]),textarea,input,select,[tabindex]:not([tabindex="-1"])'); + } + function listFocusable() { + return Array.prototype.slice.call(rootEl.querySelectorAll('a[href],button,textarea,input,select,[tabindex]')).filter(selectable); + } + var onKeydown = function(e) { + if (e.key !== 'Tab') return; + var nodes = listFocusable(); + if (!nodes.length) return; + var first = nodes[0]; + var last = nodes[nodes.length - 1]; + if (e.shiftKey) { + if (document.activeElement === first) { e.preventDefault(); last.focus(); } + } else { + if (document.activeElement === last) { e.preventDefault(); first.focus(); } + } + }; + rootEl.addEventListener('keydown', onKeydown); + _focusTrapState = { root: rootEl, onKeydown: onKeydown }; + var n = listFocusable(); + if (n.length) setTimeout(function() { n[0].focus(); }, 0); + } + function focusTrapEnd() { + if (!_focusTrapState || !_focusTrapState.root) { _focusTrapState = null; return; } + try { _focusTrapState.root.removeEventListener('keydown', _focusTrapState.onKeydown); } catch (e) {} + _focusTrapState = null; + } + + function updateNavAriaCurrentFromPath(path) { + var raw = path != null ? String(path) : (window.location.pathname || '/'); + var p = raw.split('?')[0].replace(/\/$/, '') || '/'; + var links = document.querySelectorAll('#navLinks a[href^="/"]'); + links.forEach(function(a) { + var h = (a.getAttribute('href') || '').split('?')[0].replace(/\/$/, '') || '/'; + if (h === p || (h !== '/' && (p === h || p.indexOf(h + '/') === 0))) { + a.setAttribute('aria-current', 'page'); + } else { + a.removeAttribute('aria-current'); + } + }); + } + let currentView = 'home'; let _poolsRouteTreeRefreshTimer = null; let currentDetailKey = ''; @@ -988,7 +1122,7 @@ _blocksScrollAnimationId = null; } currentView = viewName; - var detailViews = ['blockDetail','transactionDetail','addressDetail','tokenDetail','nftDetail','watchlist','searchResults','tokens','addresses','pools','routes','liquidity','more','system']; + var detailViews = ['blockDetail','transactionDetail','addressDetail','tokenDetail','nftDetail','watchlist','searchResults','tokens','addresses','pools','routes','liquidity','more','bridge','weth','system','institution','compare','analytics','operator']; if (detailViews.indexOf(viewName) === -1) currentDetailKey = ''; var homeEl = document.getElementById('homeView'); if (homeEl) homeEl.style.display = viewName === 'home' ? 'block' : 'none'; @@ -1067,7 +1201,65 @@ const operatorNav = document.getElementById('operatorNav'); if (analyticsNav) analyticsNav.style.display = hasAccess(3) ? 'block' : 'none'; if (operatorNav) operatorNav.style.display = hasAccess(4) ? 'block' : 'none'; + const userAccountNav = document.getElementById('userAccountNav'); + const verifyContractsRow = document.getElementById('userMenuVerifyContractsRow'); + const identityLabel = document.getElementById('userMenuIdentityLabel'); + if (userAccountNav) { + userAccountNav.style.display = authToken ? '' : 'none'; + } + if (verifyContractsRow) { + verifyContractsRow.style.display = authToken && hasAccess(4) ? '' : 'none'; + } + var instRow = document.getElementById('userMenuInstitutionRow'); + var cmpRow = document.getElementById('userMenuCompareRow'); + var kbRow = document.getElementById('userMenuKeyboardRow'); + var diagRow = document.getElementById('userMenuDiagnosticsRow'); + var incRow = document.getElementById('userMenuIncidentsRow'); + if (instRow) instRow.style.display = authToken && hasAccess(2) ? '' : 'none'; + if (cmpRow) cmpRow.style.display = authToken && hasAccess(2) ? '' : 'none'; + if (kbRow) kbRow.style.display = authToken ? '' : 'none'; + if (diagRow) diagRow.style.display = authToken && hasAccess(2) ? '' : 'none'; + if (incRow) incRow.style.display = authToken && hasAccess(4) ? '' : 'none'; + if (identityLabel && userAddress) { + identityLabel.textContent = 'Signed in: ' + shortenHash(userAddress); + } else if (identityLabel) { + identityLabel.textContent = 'Signed in'; + } } + + window.disconnectExplorerWallet = async function disconnectExplorerWallet() { + var tok = authToken; + if (tok) { + try { + await fetch(EXPLORER_API_V1_BASE + '/auth/logout', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + tok, 'Content-Type': 'application/json' } + }); + } catch (e) {} + } + try { + localStorage.removeItem('authToken'); + localStorage.removeItem('userAddress'); + } catch (e) {} + authToken = null; + userAddress = null; + userTrack = 1; + const walletBtn = document.getElementById('walletConnectBtn'); + const walletStatus = document.getElementById('walletStatus'); + const walletAddress = document.getElementById('walletAddress'); + if (walletBtn) { + walletBtn.style.display = 'block'; + walletBtn.disabled = false; + } + if (walletStatus) walletStatus.style.display = 'none'; + if (walletAddress) walletAddress.textContent = ''; + focusTrapEnd(); + updateUIForTrack(); + loadFeatureFlags(); + if (typeof showToast === 'function') { + showToast('Signed out', 'success'); + } + }; let connectingWallet = false; async function readApiErrorMessage(response, fallbackMessage) { @@ -2524,6 +2716,22 @@ } window._showAddresses = renderAddressesView; + window.loadInstitutionAnalyticsCohortSnapshot = async function() { + var el = document.getElementById('institutionCohortSnapshot'); + if (!el) return; + var out = { at: new Date().toISOString() }; + var strip = document.getElementById('missionControlHealthStrip'); + if (strip) out.missionControlStrip = strip.textContent; + try { + var r = await fetch(EXPLORER_TRACK1_BASE + '/bridge/status', { credentials: 'omit' }); + if (r.ok) out.bridgeStatus = await r.json(); + else out.bridgeHttp = r.status; + } catch (e) { + out.bridgeError = String(e.message || e); + } + el.textContent = JSON.stringify(out, null, 2); + }; + function buildAnalyticsViewHtml() { var html = ''; html += '
'; @@ -2543,6 +2751,11 @@ html += '
Block cadence
Inspect live block production, miner attribution, gas usage, and exportable block history.
'; html += '
Transaction flow
Review the recent transaction stream and drill into decoded execution details and internal calls.
'; html += '
Route coverage
Open the dedicated route-decision tree for swap-path coverage, bridge branches, and missing quote-token diagnostics.
'; + html += '
'; + html += '
Flow & cohort snapshot (institution)
'; + html += '

Cohort-style snapshot: combine live bridge status with the mission-control strip. Use for internal reporting until dedicated analytics endpoints are enabled.

'; + html += ''; + html += '
Click refresh after this page loads.
'; html += '
'; html += ''; return html; @@ -2773,7 +2986,7 @@ function showView(viewName) { currentView = viewName; - var detailViews = ['blockDetail','transactionDetail','addressDetail','tokenDetail','nftDetail','watchlist','searchResults','tokens','addresses','pools','routes','liquidity','more','analytics','operator','system']; + var detailViews = ['blockDetail','transactionDetail','addressDetail','tokenDetail','nftDetail','watchlist','searchResults','tokens','addresses','pools','routes','liquidity','more','analytics','operator','system','bridge','weth','institution','compare']; if (detailViews.indexOf(viewName) === -1) currentDetailKey = ''; document.querySelectorAll('.detail-view').forEach(v => v.classList.remove('active')); const homeView = document.getElementById('homeView'); @@ -2809,9 +3022,427 @@ if (typeof history !== 'undefined' && history.pushState) { history.pushState(null, '', path); } + if (typeof updateNavAriaCurrentFromPath === 'function') { + updateNavAriaCurrentFromPath(path); + } } window.updatePath = updatePath; + + window.refreshWalletJwt = async function refreshWalletJwt() { + if (!authToken) return; + try { + var r = await fetch(EXPLORER_API_V1_BASE + '/auth/refresh', { + method: 'POST', + headers: { 'Authorization': 'Bearer ' + authToken, 'Content-Type': 'application/json' } + }); + var data = await r.json().catch(function() { return null; }); + if (!r.ok || !data || !data.token) { + if (typeof showToast === 'function') showToast('Session refresh failed (HTTP ' + r.status + ').', 'error'); + return; + } + authToken = data.token; + if (data.track != null) userTrack = parseInt(data.track, 10) || userTrack; + try { localStorage.setItem('authToken', authToken); } catch (e) {} + updateUIForTrack(); + await loadFeatureFlags(); + if (typeof showToast === 'function') showToast('Session refreshed', 'success'); + if (typeof renderInstitutionView === 'function' && currentView === 'institution') renderInstitutionView(); + } catch (e) { + if (typeof showToast === 'function') showToast('Session refresh failed.', 'error'); + } + }; + + window.showInstitutionConsole = function showInstitutionConsole() { + if (!authToken || !hasAccess(2)) { + if (typeof showToast === 'function') showToast('Institution console requires track 2+ after wallet sign-in.', 'warning'); + return; + } + switchToView('institution'); + updatePath('/institution'); + renderInstitutionView(); + }; + + window.showCompareAddresses = function showCompareAddresses(a, b) { + if (!authToken || !hasAccess(2)) { + if (typeof showToast === 'function') showToast('Compare addresses requires track 2+.', 'warning'); + return; + } + var aa = a ? safeAddress(String(a)) : null; + var bb = b ? safeAddress(String(b)) : null; + switchToView('compare'); + if (aa && bb) { + updatePath('/compare/' + encodeURIComponent(aa) + '/' + encodeURIComponent(bb)); + } else { + updatePath('/compare'); + } + renderCompareView(aa || '', bb || ''); + }; + + window.openInstitutionSavedViewById = function(id) { + var views = getSavedViews(); + for (var i = 0; i < views.length; i++) { + if (views[i].id === id) { + if (typeof history !== 'undefined' && history.pushState) { + history.pushState(null, '', views[i].path); + } + applyHashRoute(); + return; + } + } + }; + + window.removeInstitutionSavedViewById = function(id) { + saveSavedViews(getSavedViews().filter(function(v) { return v.id !== id; })); + if (typeof renderInstitutionView === 'function') renderInstitutionView(); + }; + + window.saveCurrentPageAsInstitutionView = function() { + var name = window.prompt('Name for this saved view (e.g. Bridge dashboard)', 'Saved view'); + if (name == null) return; + name = String(name).trim() || 'Saved view'; + var path = window.location.pathname || '/'; + var views = getSavedViews(); + views.unshift({ id: 'sv_' + Date.now(), name: name, path: path }); + saveSavedViews(views); + if (typeof showToast === 'function') showToast('Saved view stored in this browser.', 'success'); + renderInstitutionView(); + }; + + window.promptSaveFilterPresetInstitution = function(key) { + var name = window.prompt('Preset name for ' + key, 'My filter'); + if (name == null) return; + name = String(name).trim() || 'Preset'; + var val = getExplorerPageFilter(key); + var all = getFilterPresets(); + if (!all[key]) all[key] = []; + all[key].push({ id: 'fp_' + Date.now(), name: name, value: val }); + setFilterPresets(all); + if (typeof showToast === 'function') showToast('Filter preset saved locally.', 'success'); + renderInstitutionView(); + }; + + window.applyFilterPresetInstitution = function(key, value) { + setExplorerPageFilter(key, value || ''); + if (key === 'blocksList' && typeof loadAllBlocks === 'function') loadAllBlocks(blocksListPage); + if (key === 'transactionsList' && typeof loadAllTransactions === 'function') loadAllTransactions(transactionsListPage); + if (typeof showToast === 'function') showToast('Filter applied. Open Blocks or Transactions to see results.', 'info'); + }; + + window.applyFilterPresetByIdInstitution = function(key, pid) { + var arr = (getFilterPresets()[key] || []).filter(function(p) { return p.id === pid; }); + if (!arr.length) return; + applyFilterPresetInstitution(key, arr[0].value); + }; + + window.removeFilterPresetInstitution = function(key, pid) { + var all = getFilterPresets(); + if (!all[key]) return; + all[key] = all[key].filter(function(p) { return p.id !== pid; }); + setFilterPresets(all); + renderInstitutionView(); + }; + + window.saveInstitutionNotifSettingsFromForm = function() { + var en = document.getElementById('instNotifEnabled'); + var iv = document.getElementById('instNotifInterval'); + setNotifSettings({ + enabled: !!(en && en.checked), + intervalMin: iv ? parseInt(iv.value, 10) || 60 : 60 + }); + if (typeof showToast === 'function') showToast('Notification settings saved.', 'success'); + renderInstitutionView(); + if (typeof restartWatchlistInstitutionNotifications === 'function') restartWatchlistInstitutionNotifications(); + }; + + window.saveInstitutionReportSchedule = function() { + if (!hasAccess(3)) return; + var name = window.prompt('Report schedule name', 'Weekly digest'); + if (name == null) return; + var freq = window.prompt('Frequency: manual, daily, or weekly', 'weekly'); + if (freq == null) return; + freq = String(freq).toLowerCase(); + if (freq !== 'daily' && freq !== 'weekly' && freq !== 'manual') freq = 'manual'; + var list = getReportSchedules(); + list.push({ id: 'rs_' + Date.now(), name: String(name).trim() || 'Report', frequency: freq, createdAt: new Date().toISOString() }); + setReportSchedules(list); + renderInstitutionView(); + }; + + window.removeInstitutionReportSchedule = function(rid) { + setReportSchedules(getReportSchedules().filter(function(r) { return r.id !== rid; })); + renderInstitutionView(); + }; + + function renderInstitutionView() { + var c = document.getElementById('institutionContent'); + if (!c) return; + if (!authToken || !hasAccess(2)) { + c.innerHTML = '

Institution console requires track 2+ wallet authentication.

'; + return; + } + var prefs = getInstitutionPrefs(); + var jwt = decodeExplorerJwtPayload(authToken); + var expText = '—'; + if (jwt && jwt.exp) { + expText = formatInstitutionTimestamp(jwt.exp * 1000); + if (jwt.jti) expText += ' · jti: ' + escapeHtml(String(jwt.jti).slice(0, 14)) + '…'; + } + var views = getSavedViews(); + var viewRows = views.length + ? views.map(function(v) { + return '' + escapeHtml(v.name) + '' + escapeHtml(v.path) + ' '; + }).join('') + : 'No saved views. Browse the explorer, then click “Save current page”.'; + var fp = getFilterPresets(); + function fpRows(key) { + var arr = fp[key] || []; + if (!arr.length) return 'None'; + return arr.map(function(p) { + return '' + escapeHtml(p.name) + '' + escapeHtml(p.value || '(empty)') + ' '; + }).join(''); + } + var ns = getNotifSettings(); + var rs = getReportSchedules(); + var rsRows = !hasAccess(3) + ? 'Track 3+ unlocks scheduled report metadata.' + : (rs.length ? rs.map(function(r) { + return '' + escapeHtml(r.name) + '' + escapeHtml(r.frequency) + ''; + }).join('') : 'No schedules. Add a label for compliance tracking; snapshot download is manual from this browser.'); + var apiBlock = !hasAccess(3) + ? '

Track 3+ users: RPC API keys and usage audit live under the access console (email session), not the wallet JWT. See EXPLORER_API_ACCESS.md.

' + : '

RPC products and keys: GET ' + escapeHtml(EXPLORER_API_V1_BASE) + '/access/products (public) and GET …/access/api-keys with an email access token from POST …/auth/register / login. Wallet JWTs gate explorer tracks, not the RPC key store.

'; + + c.innerHTML = '' + + '
' + + '

Session & security

' + + '

Wallet: ' + escapeHtml(userAddress || '') + ' · Track: ' + escapeHtml(String(userTrack)) + '

' + + '

JWT expiry (decoded): ' + expText + '

' + + '

Only the current browser session is shown. Revoking this token uses Sign out (server POST /auth/logout). Organization-wide “sign out everywhere” needs operator revocation of all JTIs for the address (not exposed here).

' + + '
' + + + '

Preferences (this browser)

' + + '
' + + '' + + '' + + '' + + '' + + '' + + '
' + + + '

Saved views

' + + '' + + '' + viewRows + '
NamePath
' + + + '

Filter presets (blocks / transactions lists)

' + + '

Apply filters on Blocks or Transactions pages first, then capture a preset here.

' + + ' ' + + '' + + '

Blocks

' + fpRows('blocksList') + '
NameValue
' + + '

Transactions

' + fpRows('transactionsList') + '
NameValue
' + + + '

Watchlist notifications

' + + '

While this tab is open, the explorer compares cached transaction counts for watchlist addresses on your chosen interval.

' + + '' + + ' ' + + '
' + + + (hasAccess(3) ? '

Reports & snapshots

' + + '

Download a JSON bundle for audit / ticketing. Email delivery is not wired in-browser; export and attach to your SOC workflow.

' + + ' ' + + '' + + '' + rsRows + '
NameFrequency
' : '') + + + '

API & RPC access

' + apiBlock + '
' + + + '

Address comparison

' + + '
' + + '
'; + } + + function renderCompareView(a, b) { + var c = document.getElementById('compareContent'); + if (!c) return; + if (!authToken || !hasAccess(2)) { + c.innerHTML = '

Track 2+ required.

'; + return; + } + var aa = a ? safeAddress(String(a)) : null; + var bb = b ? safeAddress(String(b)) : null; + var form = '
' + + '

' + + '

' + + '
'; + if (!aa || !bb) { + c.innerHTML = form + '

Enter two contract or EOA addresses to compare balances and activity counters side by side.

'; + return; + } + c.innerHTML = form + '
Loading both addresses…
'; + Promise.all([fetchChain138AddressDetail(aa), fetchChain138AddressDetail(bb)]).then(function(results) { + var ra = results[0] && results[0].address; + var rb = results[1] && results[1].address; + var rowBal = 'Balance (ETH)' + (ra ? escapeHtml(formatEther(ra.balance || '0')) : '—') + '' + (rb ? escapeHtml(formatEther(rb.balance || '0')) : '—') + ''; + var rowTx = 'Tx count' + (ra ? escapeHtml(String(ra.transaction_count != null ? ra.transaction_count : 0)) : '—') + '' + (rb ? escapeHtml(String(rb.transaction_count != null ? rb.transaction_count : 0)) : '—') + ''; + var rowTy = 'Type' + (ra ? (ra.is_contract ? 'Contract' : 'EOA') : '—') + '' + (rb ? (rb.is_contract ? 'Contract' : 'EOA') : '—') + ''; + var rowTk = 'Tokens' + (ra ? escapeHtml(String(ra.token_count != null ? ra.token_count : 0)) : '—') + '' + (rb ? escapeHtml(String(rb.token_count != null ? rb.token_count : 0)) : '—') + ''; + c.innerHTML = form + '' + rowBal + rowTx + rowTy + rowTk + '
MetricAB
'; + }).catch(function(err) { + c.innerHTML = form + '

' + escapeHtml(err.message || String(err)) + '

'; + }); + } + + window.closeKeyboardShortcutsModal = function() { + focusTrapEnd(); + var m = document.getElementById('keyboardShortcutsModal'); + if (m) { + m.style.display = 'none'; + m.setAttribute('aria-hidden', 'true'); + } + document.body.style.overflow = ''; + }; + window.showKeyboardShortcutsModal = function() { + var m = document.getElementById('keyboardShortcutsModal'); + if (!m) return; + if (typeof closeAllNavDropdowns === 'function') closeAllNavDropdowns(); + m.style.display = 'block'; + m.setAttribute('aria-hidden', 'false'); + document.body.style.overflow = 'hidden'; + focusTrapStart(m); + }; + + window.closeIncidentLinksModal = function() { + focusTrapEnd(); + var m = document.getElementById('incidentLinksModal'); + if (m) { + m.style.display = 'none'; + m.setAttribute('aria-hidden', 'true'); + } + document.body.style.overflow = ''; + }; + window.showIncidentLinksModal = function() { + if (!authToken || !hasAccess(4)) { + if (typeof showToast === 'function') showToast('Incident hub is limited to operator-track wallets.', 'warning'); + return; + } + var m = document.getElementById('incidentLinksModal'); + if (!m) return; + if (typeof closeAllNavDropdowns === 'function') closeAllNavDropdowns(); + m.style.display = 'block'; + m.setAttribute('aria-hidden', 'false'); + document.body.style.overflow = 'hidden'; + focusTrapStart(m); + }; + + window.copyExplorerDiagnosticsBundle = async function() { + if (!authToken) { + if (typeof showToast === 'function') showToast('Sign in to copy diagnostics.', 'warning'); + return; + } + var pl = decodeExplorerJwtPayload(authToken); + var bundle = { + exportedAt: new Date().toISOString(), + pathname: window.location.pathname, + userTrack: userTrack, + wallet: userAddress, + jwtExp: pl && pl.exp, + jwtJtiPrefix: pl && pl.jti ? String(pl.jti).slice(0, 12) : null, + chainId: typeof CHAIN_ID !== 'undefined' ? CHAIN_ID : null, + userAgent: navigator.userAgent, + explorerApi: EXPLORER_API_V1_BASE + }; + var strip = document.getElementById('missionControlHealthStrip'); + if (strip) bundle.missionControlStrip = strip.textContent; + var txt = JSON.stringify(bundle, null, 2); + try { + await navigator.clipboard.writeText(txt); + if (typeof showToast === 'function') showToast('Diagnostics bundle copied', 'success'); + } catch (e) { + window.prompt('Copy diagnostics:', txt); + } + }; + + window.downloadInstitutionReportSnapshot = async function() { + if (!hasAccess(3)) { + if (typeof showToast === 'function') showToast('Track 3+ required for report snapshot.', 'warning'); + return; + } + var snap = { + generatedAt: new Date().toISOString(), + userTrack: userTrack, + pathname: window.location.pathname, + schedules: getReportSchedules() + }; + var strip = document.getElementById('missionControlHealthStrip'); + if (strip) snap.missionControlStrip = strip.textContent; + var g = document.getElementById('gasCurrentValue'); + if (g) snap.gasHomeLabel = g.textContent; + try { + var r = await fetch(EXPLORER_TRACK1_BASE + '/bridge/status', { credentials: 'omit' }); + if (r.ok) snap.bridgeStatus = await r.json(); + } catch (e) {} + var blob = new Blob([JSON.stringify(snap, null, 2)], { type: 'application/json' }); + var url = URL.createObjectURL(blob); + var a = document.createElement('a'); + a.href = url; + a.download = 'explorer-institution-snapshot-' + snap.generatedAt.slice(0, 19).replace(/[:]/g, '-') + '.json'; + a.click(); + URL.revokeObjectURL(url); + var list = getReportSchedules(); + for (var i = 0; i < list.length; i++) { + list[i].lastDownloadAt = snap.generatedAt; + } + setReportSchedules(list); + if (typeof showToast === 'function') showToast('Snapshot downloaded', 'success'); + renderInstitutionView(); + }; + + var _watchlistNotifTimer = null; + function restartWatchlistInstitutionNotifications() { + if (_watchlistNotifTimer) { + clearInterval(_watchlistNotifTimer); + _watchlistNotifTimer = null; + } + _watchlistNotifTimer = setInterval(tickWatchlistInstitutionNotifications, 60000); + } + window.restartWatchlistInstitutionNotifications = restartWatchlistInstitutionNotifications; + + async function tickWatchlistInstitutionNotifications() { + var s = getNotifSettings(); + if (!authToken || !s.enabled) return; + var wl = getWatchlist(); + if (!wl.length) return; + var last = 0; + try { last = parseInt(sessionStorage.getItem('explorer_notif_last_run') || '0', 10); } catch (e) {} + var now = Date.now(); + if (now - last < s.intervalMin * 60000) return; + try { sessionStorage.setItem('explorer_notif_last_run', String(now)); } catch (e) {} + var snap = getNotifSnap(); + var slice = wl.slice(0, 8); + for (var i = 0; i < slice.length; i++) { + var addr = slice[i]; + try { + var res = await fetchChain138AddressDetail(addr); + var ad = res && res.address; + var tc = ad && ad.transaction_count != null ? ad.transaction_count : 0; + var prev = snap[addr.toLowerCase()]; + if (prev != null && tc > prev && typeof showToast === 'function') { + showToast('Watchlist activity: ' + shortenHash(addr) + ' tx count ' + prev + ' → ' + tc, 'info', 8000); + } + snap[addr.toLowerCase()] = tc; + } catch (e) {} + } + setNotifSnap(snap); + } + function applyHashRoute() { + try { var route = ''; var fromPath = (window.location.pathname || '/').replace(/^\//, '').replace(/\/$/, '').replace(/^index\.html$/i, ''); var fromHash = (window.location.hash || '').replace(/^#/, ''); @@ -2840,9 +3471,28 @@ if (parts[0] === 'liquidity') { if (currentView !== 'liquidity') showLiquidityAccess(); return; } if (parts[0] === 'more') { if (currentView !== 'more') showMore(); return; } if (parts[0] === 'tokens') { if (typeof showTokensList === 'function') showTokensList(); else focusSearchWithHint('token'); return; } + if (parts[0] === 'institution') { + if (!authToken || !hasAccess(2)) { if (currentView !== 'home') showHome(); return; } + if (currentView !== 'institution') switchToView('institution'); + renderInstitutionView(); + return; + } + if (parts[0] === 'compare') { + if (!authToken || !hasAccess(2)) { if (currentView !== 'home') showHome(); return; } + var c1 = parts[1] ? decode(parts[1]) : ''; + var c2 = parts[2] ? decode(parts[2]) : ''; + if (currentView !== 'compare') switchToView('compare'); + renderCompareView(c1, c2); + return; + } if (parts[0] === 'analytics') { if (currentView !== 'analytics') showAnalytics(); return; } if (parts[0] === 'operator') { if (currentView !== 'operator') showOperator(); return; } if (parts[0] === 'system') { if (currentView !== 'system') showSystemTopology(); return; } + } finally { + if (typeof updateNavAriaCurrentFromPath === 'function') { + updateNavAriaCurrentFromPath(window.location.pathname); + } + } } window.applyHashRoute = applyHashRoute; var hasRouteOnReady = window.location.hash || ((window.location.pathname || '').replace(/^\//, '').replace(/\/$/, '')); @@ -4940,6 +5590,161 @@ if (/^0x0{40}$/i.test(s)) return null; return s; } + + function guessContractAddressFromPath() { + var path = (typeof window !== 'undefined' && window.location && window.location.pathname) ? window.location.pathname : ''; + var m = path.match(/^\/addresses\/(0x[a-fA-F0-9]{40})\/?/i); + if (m) return m[1]; + m = path.match(/^\/address\/(0x[a-fA-F0-9]{40})\/?/i); + return m ? m[1] : ''; + } + + function institutionExternalLinksBlockscout() { + return (getInstitutionPrefs().externalLinks || 'spa') === 'blockscout'; + } + function clearExplorerPendingAddressInitialTabFor(addr) { + var p = window.__explorerPendingAddressInitialTab; + var low = addr && String(addr).toLowerCase(); + if (p && low && p.address === low) { + window.__explorerPendingAddressInitialTab = null; + } + } + function institutionOpenContractInExplorer(addr) { + var a = safeAddress(addr); + if (!a) return; + try { + window.__explorerPendingAddressInitialTab = { tab: 'contract', address: a.toLowerCase() }; + if (typeof showAddressDetail === 'function') showAddressDetail(a); + if (typeof updatePath === 'function') updatePath('/addresses/' + a + '/contract'); + } catch (e) {} + } + window.institutionOpenContractInExplorer = institutionOpenContractInExplorer; + + function institutionContractExploreAnchor(addr, extLabel, spaLabel, inlineStyle) { + var a = safeAddress(addr); + if (!a) return ''; + var sty = inlineStyle ? ' style="' + escapeAttr(inlineStyle) + '"' : ''; + if (institutionExternalLinksBlockscout()) { + return '' + escapeHtml(extLabel) + ''; + } + var addressForJs = a.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); + return '' + escapeHtml(spaLabel) + ''; + } + function institutionAddressContractLinkHtml(addr) { + return institutionContractExploreAnchor(addr, 'View contract on Blockscout', 'Open contract tab', 'color: var(--primary); font-size: 0.875rem;'); + } + + function syncContractVerifyBlockscoutLink() { + var input = document.getElementById('contractVerifyAddressInput'); + var link = document.getElementById('contractVerifyBlockscoutLink'); + if (!link) return; + var raw = input && input.value ? String(input.value).trim() : ''; + var addr = safeAddress(raw) || safeAddress(guessContractAddressFromPath()); + link.onclick = null; + if (addr) { + if (institutionExternalLinksBlockscout()) { + link.href = EXPLORER_ORIGIN + '/address/' + encodeURIComponent(addr) + '/contract'; + link.setAttribute('target', '_blank'); + link.setAttribute('rel', 'noopener noreferrer'); + } else { + link.href = '#'; + link.removeAttribute('target'); + link.removeAttribute('rel'); + (function (contractAddr) { + link.onclick = function (ev) { + ev.preventDefault(); + institutionOpenContractInExplorer(contractAddr); + if (typeof closeContractVerifyHelpModal === 'function') closeContractVerifyHelpModal(); + return false; + }; + })(addr); + } + link.setAttribute('aria-disabled', 'false'); + link.style.pointerEvents = ''; + link.style.opacity = ''; + } else { + link.href = '#'; + link.removeAttribute('target'); + link.removeAttribute('rel'); + link.onclick = function (ev) { ev.preventDefault(); return false; }; + link.setAttribute('aria-disabled', 'true'); + link.style.pointerEvents = 'none'; + link.style.opacity = '0.55'; + } + } + + function closeContractVerifyHelpModal() { + if (typeof focusTrapEnd === 'function') focusTrapEnd(); + var modal = document.getElementById('contractVerifyHelpModal'); + if (!modal) return; + modal.style.display = 'none'; + modal.setAttribute('aria-hidden', 'true'); + document.body.style.overflow = ''; + } + + window.showContractVerificationHelp = function showContractVerificationHelp(prefillAddress) { + if (!authToken || !hasAccess(4)) { + if (typeof showToast === 'function') { + showToast('Contract verification tools are available to operator-approved accounts.', 'warning'); + } else { + alert('Contract verification tools are available to operator-approved accounts.'); + } + return; + } + var modal = document.getElementById('contractVerifyHelpModal'); + if (!modal) return; + if (typeof closeAllNavDropdowns === 'function') { + closeAllNavDropdowns(); + } + var input = document.getElementById('contractVerifyAddressInput'); + var fromArg = prefillAddress && safeAddress(prefillAddress); + var fromPath = guessContractAddressFromPath(); + var initial = fromArg || fromPath || (input && input.value ? safeAddress(input.value) : null); + if (input) { + input.value = initial || ''; + } + syncContractVerifyBlockscoutLink(); + modal.style.display = 'block'; + modal.setAttribute('aria-hidden', 'false'); + document.body.style.overflow = 'hidden'; + if (typeof focusTrapStart === 'function') focusTrapStart(modal); + }; + + function initContractVerifyHelpModal() { + var modal = document.getElementById('contractVerifyHelpModal'); + var backdrop = document.getElementById('contractVerifyHelpBackdrop'); + var closeBtn = document.getElementById('contractVerifyHelpCloseBtn'); + var input = document.getElementById('contractVerifyAddressInput'); + var copyBtn = document.getElementById('contractVerifyCopyCommandBtn'); + var cmdEl = document.getElementById('contractVerifyScriptCommand'); + if (backdrop) { + backdrop.addEventListener('click', closeContractVerifyHelpModal); + } + if (closeBtn) { + closeBtn.addEventListener('click', function (e) { + e.preventDefault(); + closeContractVerifyHelpModal(); + }); + } + if (input) { + input.addEventListener('input', syncContractVerifyBlockscoutLink); + } + if (copyBtn && cmdEl) { + copyBtn.addEventListener('click', function (e) { + e.preventDefault(); + var text = cmdEl.textContent || './scripts/verify/run-contract-verification-with-proxy.sh'; + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).then(function () { + if (typeof showToast === 'function') showToast('Command copied', 'success'); + }).catch(function () { + window.prompt('Copy:', text); + }); + } else { + window.prompt('Copy:', text); + } + }); + } + } function escapeJsSingleQuoted(value) { return String(value == null ? '' : value).replace(/\\/g, '\\\\').replace(/'/g, "\\'"); } @@ -5942,6 +6747,7 @@ try { // Validate address format if (!/^0x[a-fA-F0-9]{40}$/.test(address)) { + clearExplorerPendingAddressInitialTabFor(address); container.innerHTML = '
Invalid address format
'; return; } @@ -5959,6 +6765,7 @@ throw new Error('Address not found'); } } catch (error) { + clearExplorerPendingAddressInitialTabFor(address); var retryAddress = String(address || '').replace(/\\/g, '\\\\').replace(/'/g, "\\'"); container.innerHTML = '
Failed to load address: ' + escapeHtml(error.message || 'Unknown error') + '.
'; return; @@ -5975,19 +6782,25 @@ if (a) { const balanceEth = formatEther(a.balance || '0'); const isContract = !!a.is_contract; + if (window.__explorerPendingAddressInitialTab && window.__explorerPendingAddressInitialTab.tab === 'contract' && window.__explorerPendingAddressInitialTab.address === address.toLowerCase() && !isContract) { + window.__explorerPendingAddressInitialTab = null; + } const verifiedBadge = a.is_verified ? 'Verified' : ''; const encodedAddress = encodeURIComponent(address); const escapedAddress = escapeHtml(address); const addressForJs = address.replace(/\\/g, '\\\\').replace(/'/g, "\\'"); - const contractLink = isContract ? `View contract on Blockscout` : ''; + const contractLink = isContract ? institutionAddressContractLinkHtml(address) : ''; const savedLabel = getAddressLabel(address); const inWatchlist = isInWatchlist(address); const fallbackNotice = addressDetailSource === 'rpc_fallback' ? `
RPC fallback mode. Indexed address metadata is temporarily unavailable or responding too slowly, so this view is using live RPC for balance, nonce-derived transaction count, and contract detection. Transactions, token balances, internal txns, and NFTs remain best-effort while the explorer API recovers.
` : ''; + const institutionOpStrip = (isContract && authToken && hasAccess(4)) + ? `
Institution operator tools · ${contractLink ? ' · ' + contractLink : ''}
` + : ''; container.innerHTML = ` - ${fallbackNotice} + ${fallbackNotice}${institutionOpStrip}
Address
${escapedAddress}
@@ -6084,6 +6897,11 @@ } } window.switchAddressTab = switchAddressTab; + var pend = window.__explorerPendingAddressInitialTab; + if (pend && pend.tab === 'contract' && pend.address === address.toLowerCase() && isContract) { + window.__explorerPendingAddressInitialTab = null; + switchAddressTab('contract', address); + } async function loadAddressTokenBalances(addr) { const el = document.getElementById('addressTokenBalances'); @@ -6243,7 +7061,7 @@ } el.dataset.loaded = '1'; if (!data) { - el.innerHTML = '

Contract source not indexed. Verify on Blockscout

'; + el.innerHTML = '

Contract source not indexed. ' + institutionContractExploreAnchor(addr, 'Verify on Blockscout', 'Open contract tab', 'color: var(--primary);') + '

'; return; } const abi = data.abi || data.abi_interface || []; @@ -6271,7 +7089,7 @@ html += ''; html += '
'; } - html += '

Read / Write contract on Blockscout

'; + html += '

' + institutionContractExploreAnchor(addr, 'Read / Write contract on Blockscout', 'Read / Write contract in explorer', 'color: var(--primary);') + '

'; el.innerHTML = html; if (viewFns.length > 0) { (function setupReadContract(contractAddr, abiJson, viewFunctions) { @@ -6434,9 +7252,11 @@ if (txContainer) txContainer.innerHTML = '

Failed to load transactions

'; } } else { + clearExplorerPendingAddressInitialTabFor(address); container.innerHTML = '
Address not found
'; } } catch (error) { + clearExplorerPendingAddressInitialTabFor(address); container.innerHTML = '
Failed to load address: ' + escapeHtml(error.message) + '
'; } } @@ -7192,6 +8012,24 @@ closeSmartSearchModal(); return; } + var kbdModal = document.getElementById('keyboardShortcutsModal'); + if (e.key === 'Escape' && kbdModal && kbdModal.style.display === 'block') { + e.preventDefault(); + if (typeof closeKeyboardShortcutsModal === 'function') closeKeyboardShortcutsModal(); + return; + } + var incModal = document.getElementById('incidentLinksModal'); + if (e.key === 'Escape' && incModal && incModal.style.display === 'block') { + e.preventDefault(); + if (typeof closeIncidentLinksModal === 'function') closeIncidentLinksModal(); + return; + } + var contractHelpModal = document.getElementById('contractVerifyHelpModal'); + if (e.key === 'Escape' && contractHelpModal && contractHelpModal.style.display === 'block') { + e.preventDefault(); + closeContractVerifyHelpModal(); + return; + } if ((e.key === '/' || ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k')) && !isEditable) { e.preventDefault(); openSmartSearchModal(''); @@ -7203,6 +8041,24 @@ if (e.target.closest('a')) closeNavMenu(); }); initNavDropdowns(); + initContractVerifyHelpModal(); + var kbdBackdrop = document.getElementById('keyboardShortcutsBackdrop'); + var kbdClose = document.getElementById('keyboardShortcutsCloseBtn'); + if (kbdBackdrop) kbdBackdrop.addEventListener('click', function() { if (typeof closeKeyboardShortcutsModal === 'function') closeKeyboardShortcutsModal(); }); + if (kbdClose) kbdClose.addEventListener('click', function(ev) { ev.preventDefault(); if (typeof closeKeyboardShortcutsModal === 'function') closeKeyboardShortcutsModal(); }); + var incBackdrop = document.getElementById('incidentLinksBackdrop'); + var incClose = document.getElementById('incidentLinksCloseBtn'); + if (incBackdrop) incBackdrop.addEventListener('click', function() { if (typeof closeIncidentLinksModal === 'function') closeIncidentLinksModal(); }); + if (incClose) incClose.addEventListener('click', function(ev) { ev.preventDefault(); if (typeof closeIncidentLinksModal === 'function') closeIncidentLinksModal(); }); + } + if (typeof updateNavAriaCurrentFromPath === 'function') { + updateNavAriaCurrentFromPath(window.location.pathname); + } + if (typeof restartWatchlistInstitutionNotifications === 'function') { + restartWatchlistInstitutionNotifications(); + } + if (typeof applyInstitutionPrefsToDom === 'function') { + applyInstitutionPrefsToDom(); } ensureMissionControlHealthStrip(); startMissionControlEventSource(); diff --git a/frontend/public/index.html b/frontend/public/index.html index 5b7d67c..003c9d6 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -156,6 +156,15 @@ body.dark-theme .table th { background: #334155; color: var(--text); } body.dark-theme .table tr:hover { background: #1e293b; } body.dark-theme .skeleton { background: linear-gradient(90deg, #334155 25%, #475569 50%, #334155 75%); } + body.explorer-compact-table-rows .table tbody td, + body.explorer-compact-table-rows .table tbody th { + padding: 0.35rem 0.5rem; + font-size: 0.85rem; + } + body.explorer-compact-table-rows .table thead th { + padding: 0.45rem 0.55rem; + font-size: 0.82rem; + } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif; background: var(--light); @@ -267,6 +276,31 @@ opacity: 1; } .nav-dropdown-menu li { margin: 0; } + .nav-dropdown-menu li.user-menu-meta { + padding: 0.55rem 1rem 0.65rem; + font-size: 0.8rem; + color: rgba(255,255,255,0.72); + border-bottom: 1px solid rgba(255,255,255,0.12); + margin-bottom: 0.15rem; + } + .nav-links a:focus-visible, + .nav-dropdown-trigger:focus-visible, + .nav-actions button:focus-visible, + .nav-actions select:focus-visible { + outline: 2px solid rgba(255,255,255,0.95); + outline-offset: 2px; + } + body.dark-theme .nav-links a:focus-visible, + body.dark-theme .nav-dropdown-trigger:focus-visible { + outline-color: #fbbf24; + } + .nav-actions { + flex-wrap: wrap; + justify-content: flex-end; + } + @media (max-width: 1100px) { + .nav-actions #walletConnect { width: 100%; justify-content: flex-end; margin-top: 0.35rem; } + } .search-box { flex: 1; max-width: 560px; @@ -1251,6 +1285,19 @@
  • MetaMask Snap
  • Operations
  • + +
    +
    Proxmox repo (LAN + Foundry)
    +

    From the repository root on a host that can reach Blockscout and your RPC (see forge-verification-proxy in-repo):

    +
    + ./scripts/verify/run-contract-verification-with-proxy.sh + +
    +
    +

    The public SPA cannot run forge verify. Use Blockscout’s form or the script above. Optional alternate bridge verify: set VERIFY_ALTERNATE_CCIPWETH9_BRIDGE=1 when using scripts/verify-contracts-blockscout.sh (see repo docs).

    + + + + + + + + +
    @@ -1778,6 +1895,32 @@
    + +
    + +
    +
    + +

    Institution console

    +
    +
    +
    Loading…
    +
    +
    +
    + +
    + +
    +
    + +

    Compare addresses

    +
    +
    +
    Loading…
    +
    +
    +
    - + diff --git a/frontend/public/token-icons/chain-138.png b/frontend/public/token-icons/chain-138.png new file mode 100644 index 0000000..ae7dea3 Binary files /dev/null and b/frontend/public/token-icons/chain-138.png differ diff --git a/frontend/src/components/common/EntityBadge.tsx b/frontend/src/components/common/EntityBadge.tsx index 773f211..a5dc730 100644 --- a/frontend/src/components/common/EntityBadge.tsx +++ b/frontend/src/components/common/EntityBadge.tsx @@ -13,8 +13,14 @@ function toneClasses(tone: 'neutral' | 'success' | 'warning' | 'info') { } } -export function getEntityBadgeTone(tag: string): 'neutral' | 'success' | 'warning' | 'info' { - const normalized = tag.toLowerCase() +function normalizeBadgeLabel(value: unknown): string { + if (typeof value === 'string') return value + if (typeof value === 'number' || typeof value === 'bigint' || typeof value === 'boolean') return String(value) + return 'unknown' +} + +export function getEntityBadgeTone(tag: unknown): 'neutral' | 'success' | 'warning' | 'info' { + const normalized = normalizeBadgeLabel(tag).toLowerCase() if (normalized === 'compliant' || normalized === 'listed' || normalized === 'verified' || normalized === 'gru') { return 'success' } @@ -27,15 +33,16 @@ export function getEntityBadgeTone(tag: string): 'neutral' | 'success' | 'warnin return 'neutral' } -export function formatEntityBadgeLabel(label: string): string { - const normalized = label.toLowerCase() +export function formatEntityBadgeLabel(label: unknown): string { + const resolvedLabel = normalizeBadgeLabel(label) + const normalized = resolvedLabel.toLowerCase() const labels: Record = { 'reference-asset': 'reference asset', 'electronic-money': 'cash e-money', 'treasury-bond': 'treasury / gov bond', gru: 'GRU', } - return labels[normalized] || label + return labels[normalized] || resolvedLabel } export default function EntityBadge({ @@ -43,7 +50,7 @@ export default function EntityBadge({ tone, className, }: { - label: string + label: unknown tone?: 'neutral' | 'success' | 'warning' | 'info' className?: string }) { diff --git a/frontend/src/components/wallet/AddToMetaMask.tsx b/frontend/src/components/wallet/AddToMetaMask.tsx index 34b2df9..906f825 100644 --- a/frontend/src/components/wallet/AddToMetaMask.tsx +++ b/frontend/src/components/wallet/AddToMetaMask.tsx @@ -84,6 +84,32 @@ export type CapabilitiesCatalog = { } } +type WatchAssetEntry = { + type: 'ERC20' + options: { + address: string + symbol: string + decimals: number + image?: string + } + metadata?: { + name?: string + registryFamily?: string + familySymbol?: string + deploymentVersion?: string + deploymentStatus?: string + } +} + +type MetaMaskConfig = { + source?: string + version?: string + chainId?: number + addEthereumChain?: WalletChain + watchAssets?: WatchAssetEntry[] + caveats?: string[] +} + export type FetchMetadata = { source?: string | null lastModified?: string | null @@ -109,7 +135,11 @@ const FALLBACK_CHAIN_138: WalletChain = { nativeCurrency: { name: 'Ether', symbol: 'ETH', decimals: 18 }, rpcUrls: ['https://rpc-http-pub.d-bis.org', 'https://rpc.d-bis.org', 'https://rpc2.d-bis.org'], blockExplorerUrls: ['https://explorer.d-bis.org', 'https://blockscout.defi-oracle.io'], - iconUrls: ['https://raw.githubusercontent.com/ethereum/ethereum.org/main/static/images/eth-diamond-black.png'], + iconUrls: [ + 'https://explorer.d-bis.org/api/v1/report/logo/chain-138', + 'https://explorer.d-bis.org/token-icons/chain-138.png', + 'https://explorer.d-bis.org/favicon.ico', + ], shortName: 'dbis', infoURL: 'https://explorer.d-bis.org', explorerApiUrl: 'https://explorer.d-bis.org/api/v2', @@ -139,6 +169,22 @@ const FALLBACK_ALL_MAINNET: WalletChain = { infoURL: 'https://alltra.global', } +const MAINNET_CWUSDC_TOKEN: TokenListToken = { + chainId: 1, + address: '0x2de5F116bFcE3d0f922d9C8351e0c5Fc24b9284a', + symbol: 'cWUSDC', + name: 'Wrapped cUSDC', + decimals: 6, + logoURI: 'https://explorer.d-bis.org/api/v1/report/logo/cUSDC?v=20260510', + tags: ['mainnet', 'cw', 'usd'], + extensions: { + registryFamily: 'iso4217', + familySymbol: 'USD', + canonicalSourceChainId: 138, + canonicalSourceSymbol: 'cUSDC', + }, +} + const FEATURED_TOKEN_SYMBOLS = ['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cXAUT'] /** npm-published Snap using open Snap permissions only; stable MetaMask still requires MetaMask’s install allowlist. */ @@ -218,12 +264,62 @@ function isCapabilitiesCatalog(value: unknown): value is CapabilitiesCatalog { ) } +function isWatchAssetEntry(value: unknown): value is WatchAssetEntry { + if (!value || typeof value !== 'object') return false + + const candidate = value as Partial + const options = (candidate.options || {}) as Partial + return ( + candidate.type === 'ERC20' && + typeof options.address === 'string' && + options.address.trim().length > 0 && + typeof options.symbol === 'string' && + options.symbol.trim().length > 0 && + typeof options.decimals === 'number' + ) +} + +function isMetaMaskConfig(value: unknown): value is MetaMaskConfig { + if (!value || typeof value !== 'object') return false + + const candidate = value as Partial + return ( + typeof candidate.chainId === 'number' && + !!candidate.addEthereumChain && + Array.isArray(candidate.watchAssets) + ) +} + +function watchAssetToToken(entry: WatchAssetEntry): TokenListToken { + return { + chainId: 138, + address: entry.options.address, + symbol: entry.options.symbol, + name: entry.metadata?.name || entry.options.symbol, + decimals: entry.options.decimals, + logoURI: entry.options.image, + extensions: { + registryFamily: entry.metadata?.registryFamily, + familySymbol: entry.metadata?.familySymbol, + deploymentVersion: entry.metadata?.deploymentVersion, + deploymentStatus: entry.metadata?.deploymentStatus, + }, + } +} + function getApiBase() { return resolveExplorerApiBase({ - serverFallback: 'https://blockscout.defi-oracle.io', + browserOrigin: '', + serverFallback: 'https://explorer.d-bis.org', }) } +function formatStableTimestamp(value: string): string { + const timestamp = Date.parse(value) + if (Number.isNaN(timestamp)) return value + return new Date(timestamp).toISOString() +} + export function AddToMetaMask({ initialNetworks = null, initialTokenList = null, @@ -253,19 +349,20 @@ export function AddToMetaMask({ lastModified: FALLBACK_CAPABILITIES_138.timestamp || null, }), ) + const [metamaskConfig, setMetamaskConfig] = useState(null) + const [metamaskConfigMeta, setMetamaskConfigMeta] = useState(null) + const [watchAssetProgress, setWatchAssetProgress] = useState<{ current: number; total: number } | null>(null) const ethereum = typeof window !== 'undefined' ? (window as unknown as { ethereum?: EthereumProvider }).ethereum : undefined const apiBase = getApiBase().replace(/\/$/, '') - const tokenListUrl = `${apiBase}/api/config/token-list` + const tokenListUrl = `${apiBase}/api/v1/report/token-list?chainId=138` const networksUrl = `${apiBase}/api/config/networks` + const metamaskConfigUrl = `${apiBase}/api/v1/config/metamask?chainId=138` const capabilitiesUrl = `${apiBase}/api/config/capabilities` - const staticCapabilitiesUrl = - typeof window !== 'undefined' - ? `${window.location.origin.replace(/\/$/, '')}/config/CHAIN138_RPC_CAPABILITIES.json` - : `${apiBase}/config/CHAIN138_RPC_CAPABILITIES.json` + const staticCapabilitiesUrl = `${apiBase}/config/CHAIN138_RPC_CAPABILITIES.json` useEffect(() => { let active = true @@ -293,6 +390,7 @@ export function AddToMetaMask({ fetchJson(tokenListUrl), fetchJson(capabilitiesUrl), ]) + const metamaskConfigResponse = await fetchJson(metamaskConfigUrl).catch(() => null) let resolvedCapabilities = capabilitiesResponse if (!isCapabilitiesCatalog(resolvedCapabilities.json)) { @@ -320,6 +418,10 @@ export function AddToMetaMask({ setNetworks(networksResponse.json) setTokenList(tokenListResponse.json) setCapabilities(resolvedCapabilities.json) + if (isMetaMaskConfig(metamaskConfigResponse?.json)) { + setMetamaskConfig(metamaskConfigResponse.json) + setMetamaskConfigMeta(metamaskConfigResponse.meta) + } setNetworksMeta(networksResponse.meta) setTokenListMeta(tokenListResponse.meta) setCapabilitiesMeta(resolvedCapabilities.meta) @@ -328,6 +430,7 @@ export function AddToMetaMask({ setNetworks((current) => current) setTokenList((current) => current) setCapabilities((current) => current || FALLBACK_CAPABILITIES_138) + setMetamaskConfig((current) => current) setNetworksMeta((current) => current) setTokenListMeta((current) => current) setCapabilitiesMeta((current) => @@ -351,7 +454,7 @@ export function AddToMetaMask({ active = false if (timer) clearTimeout(timer) } - }, [capabilitiesUrl, networksUrl, staticCapabilitiesUrl, tokenListUrl]) + }, [capabilitiesUrl, metamaskConfigUrl, networksUrl, staticCapabilitiesUrl, tokenListUrl]) const catalogTokens = useMemo( () => (Array.isArray(tokenList?.tokens) ? tokenList.tokens.filter(isTokenListToken) : []), @@ -367,12 +470,12 @@ export function AddToMetaMask({ } return { - chain138: chainMap.get(138) || FALLBACK_CHAIN_138, + chain138: metamaskConfig?.addEthereumChain || chainMap.get(138) || FALLBACK_CHAIN_138, ethereum: chainMap.get(1) || FALLBACK_ETHEREUM, allMainnet: chainMap.get(651940) || FALLBACK_ALL_MAINNET, total: (networks?.chains || []).length, } - }, [networks]) + }, [metamaskConfig, networks]) const featuredTokens = useMemo(() => { const tokenMap = new Map() @@ -387,6 +490,15 @@ export function AddToMetaMask({ .filter((token): token is TokenListToken => !!token) }, [catalogTokens]) + const watchAssetTokens = useMemo(() => { + const endpointTokens = (metamaskConfig?.watchAssets || []) + .filter(isWatchAssetEntry) + .map(watchAssetToToken) + + if (endpointTokens.length > 0) return endpointTokens + return catalogTokens.filter((token) => token.chainId === 138) + }, [catalogTokens, metamaskConfig]) + const addChain = async (chain: WalletChain) => { setError(null) setStatus(null) @@ -412,6 +524,39 @@ export function AddToMetaMask({ } } + const switchOrAddChain = async (chain: WalletChain) => { + if (!ethereum) { + setError('MetaMask or another Web3 wallet is not installed.') + return false + } + + try { + await ethereum.request({ + method: 'wallet_switchEthereumChain', + params: [{ chainId: chain.chainId }], + }) + return true + } catch (e) { + const err = e as { code?: number; message?: string } + if (err.code !== 4902) { + setError(err.message || `Failed to switch to ${chain.chainName}.`) + return false + } + } + + try { + await ethereum.request({ + method: 'wallet_addEthereumChain', + params: [chain], + }) + return true + } catch (e) { + const err = e as { message?: string } + setError(err.message || `Failed to add ${chain.chainName}.`) + return false + } + } + const installOpenSnap = async () => { setError(null) setStatus(null) @@ -435,7 +580,7 @@ export function AddToMetaMask({ const allowlistBlocked = /allowlist/i.test(msg) if (allowlistBlocked && msg) { setError( - `${msg} Production MetaMask only installs allowlisted Snaps from npm. Use MetaMask Flask for unrestricted installs during development, or request allowlisting via MetaMask’s Snaps documentation.`, + `${msg} This is expected on Stable MetaMask until this exact Snap package and version are accepted on MetaMask's install allowlist. The production path on this page is Add Chain 138 plus EIP-747 Add Tokens; use MetaMask Flask for Snap testing or submit/update the Snap allowlist request before using this button with Stable MetaMask.`, ) } else { setError( @@ -481,6 +626,63 @@ export function AddToMetaMask({ } } + const refreshMainnetCwusdc = async () => { + setError(null) + setStatus(null) + + const switched = await switchOrAddChain(chains.ethereum) + if (!switched) return + + await watchToken(MAINNET_CWUSDC_TOKEN) + } + + const watchTokensSequentially = async (tokens: TokenListToken[], label: string) => { + setError(null) + setStatus(null) + setWatchAssetProgress(null) + + if (!ethereum) { + setError('MetaMask or another Web3 wallet is not installed.') + return + } + + const validTokens = tokens.filter(isTokenListToken) + if (validTokens.length === 0) { + setError('No complete token metadata is available for wallet_watchAsset right now.') + return + } + + let addedCount = 0 + for (let index = 0; index < validTokens.length; index += 1) { + const token = validTokens[index] + setWatchAssetProgress({ current: index + 1, total: validTokens.length }) + try { + const added = await ethereum.request({ + method: 'wallet_watchAsset', + params: { + type: 'ERC20', + options: { + address: token.address, + symbol: token.symbol, + decimals: token.decimals, + image: token.logoURI, + }, + }, + }) + if (added) addedCount += 1 + } catch (e) { + const err = e as { message?: string } + setError(err.message || `Stopped while adding ${token.symbol}.`) + setStatus(`${addedCount} of ${validTokens.length} ${label} token requests were accepted before the flow stopped.`) + setWatchAssetProgress(null) + return + } + } + + setWatchAssetProgress(null) + setStatus(`${addedCount} of ${validTokens.length} ${label} token requests were accepted by the wallet.`) + } + const copyText = async (value: string, label: string) => { setError(null) setStatus(null) @@ -510,8 +712,8 @@ export function AddToMetaMask({ The wallet tools now read the same explorer-served network catalog and token list that MetaMask can consume. That keeps chain metadata, token metadata, and optional extensions aligned with the live explorer API instead of relying on stale frontend-only defaults. MetaMask does not run built-in token detection on custom networks such - as Chain 138: add the token list URL below under Settings → Security & privacy → Token lists so tokens and - icons load automatically when you are on this chain. + as Chain 138, so this page uses EIP-747 wallet_watchAsset prompts from the live MetaMask payload to add token + metadata directly to the wallet.

    @@ -538,17 +740,19 @@ export function AddToMetaMask({
    -
    -
    Chain 138 Open Snap
    +
    +
    Optional Chain 138 Open Snap

    - Optional MetaMask Snap that uses{' '} + This is not required for the production + wallet flow above. The normal production path is to add Chain 138, then add tokens through EIP-747 + wallet_watchAsset prompts. The optional Snap uses{' '} only open Snap permissions (minimal privileged APIs in the Snap itself).{' '} Stable MetaMask still only installs npm - Snaps that appear on MetaMask's install allowlist; if install fails with "not on the allowlist", - use MetaMask Flask for development or apply - for allowlisting. It adds in-wallet weekly reminders, Chain 138 transaction/signature hints, and the token list - URL on the Snap home page. The package on npm is{' '} + Snaps that appear on MetaMask's install allowlist; if install fails with "not on the allowlist", that is + an external MetaMask review gate rather than an explorer/network failure. Use{' '} + MetaMask Flask for development or apply + for allowlisting before using this with Stable MetaMask. The package on npm is{' '} {CHAIN138_OPEN_SNAP_ID} — publish from the repo with scripts/deployment/publish-chain138-open-snap.sh after{' '} npm login. @@ -556,9 +760,9 @@ export function AddToMetaMask({

    @@ -568,8 +772,10 @@ export function AddToMetaMask({

    Networks catalog: {chains.total > 0 ? `${chains.total} chains` : 'using frontend fallback values'}

    Chain 138 token entries: {tokenCount138}

    +

    EIP-747 watchAsset entries: {watchAssetTokens.length}

    Networks source: {networksMeta?.source || 'unknown'}

    Token list source: {tokenListMeta?.source || 'unknown'}

    +

    MetaMask payload source: {metamaskConfigMeta?.source || 'unknown'}

    {metadataKeywordString ?

    Keywords: {metadataKeywordString}

    : null}
    @@ -597,6 +803,18 @@ export function AddToMetaMask({
    +
    +

    EIP-747 MetaMask payload URL

    + {metamaskConfigUrl} +
    + + + Open JSON + +
    +

    Token list URL

    {tokenListUrl} @@ -653,7 +871,7 @@ export function AddToMetaMask({ ))} {capabilitiesMeta?.lastModified ? (

    - Last modified: {new Date(capabilitiesMeta.lastModified).toLocaleString()} + Last modified: {formatStableTimestamp(capabilitiesMeta.lastModified)}

    ) : null}
    @@ -662,9 +880,31 @@ export function AddToMetaMask({
    Featured Chain 138 tokens

    - These tokens come from the explorer token list and use `wallet_watchAsset` so the wallet gets the same symbol, - decimals, image, and optional token metadata that the explorer publishes. + These tokens come from the explorer MetaMask payload and use wallet_watchAsset so the wallet gets the same + symbol, decimals, image, and optional token metadata that the explorer publishes. MetaMask requires a user + approval for each token, so the bulk actions below run as a guided sequence of wallet prompts.

    +
    + + + {watchAssetProgress ? ( + + Prompt {watchAssetProgress.current} of {watchAssetProgress.total} + + ) : null} +
    {featuredTokens.length === 0 ? (

    Featured token metadata is not available right now.

    @@ -698,6 +938,35 @@ export function AddToMetaMask({ ))}
    + +
    +
    Ethereum Mainnet cWUSDC
    +

    + This refreshes the Mainnet cWUSDC custom asset metadata with the DBIS-hosted image URL. MetaMask fiat price + display still depends on MetaMask and upstream asset/price providers accepting the Mainnet listing. +

    +
    +
    +
    +
    + {MAINNET_CWUSDC_TOKEN.symbol}{' '} + ({MAINNET_CWUSDC_TOKEN.name}) +
    +
    {MAINNET_CWUSDC_TOKEN.address}
    +
    + Ethereum Mainnet • Decimals: {MAINNET_CWUSDC_TOKEN.decimals} +
    +
    + +
    +
    +
    {status ?

    {status}

    : null} diff --git a/scripts/apply-nginx-token-aggregation-proxy.sh b/scripts/apply-nginx-token-aggregation-proxy.sh index 68eb5f1..91c3e72 100755 --- a/scripts/apply-nginx-token-aggregation-proxy.sh +++ b/scripts/apply-nginx-token-aggregation-proxy.sh @@ -1,13 +1,14 @@ #!/usr/bin/env bash # Add nginx proxy for token-aggregation service at /api/v1/ on explorer.d-bis.org (VMID 5000). -# Run on the explorer VM. Requires token-aggregation running (default port 3000). +# Run on the explorer VM. Requires token-aggregation listening on TOKEN_AGG_PORT (VMID 5000 +# production default 3001 per fix-nginx-conflicts-vmid5000.sh; local dev often PORT=3000). # Chain 138 Snap companion site (GATSBY_SNAP_API_BASE_URL=https://explorer.d-bis.org) then gets # market data, swap quotes, and bridge routes from this API. -# Usage: [TOKEN_AGG_PORT=3000] [CONFIG_FILE=/etc/nginx/sites-available/blockscout] bash apply-nginx-token-aggregation-proxy.sh +# Usage: [TOKEN_AGG_PORT=3001] [CONFIG_FILE=/etc/nginx/sites-available/blockscout] bash apply-nginx-token-aggregation-proxy.sh set -euo pipefail -TOKEN_AGG_PORT="${TOKEN_AGG_PORT:-3000}" +TOKEN_AGG_PORT="${TOKEN_AGG_PORT:-3001}" CONFIG_FILE="${CONFIG_FILE:-/etc/nginx/sites-available/blockscout}" if [ ! -f "$CONFIG_FILE" ]; then diff --git a/scripts/comprehensive-link-deployment.sh b/scripts/comprehensive-link-deployment.sh index 55d21ce..2139086 100755 --- a/scripts/comprehensive-link-deployment.sh +++ b/scripts/comprehensive-link-deployment.sh @@ -128,5 +128,5 @@ echo "" echo "If deployment still pending:" echo " 1. Wait additional time (5-10 minutes)" echo " 2. Use Remix IDE (instructions above)" -echo " 3. Check block explorer: https://explorer.d-bis.org/address/$ACCOUNT" +echo " 3. Check block explorer: https://explorer.d-bis.org/addresses/$ACCOUNT" echo "" diff --git a/scripts/deploy-and-verify-link.sh b/scripts/deploy-and-verify-link.sh index 1916fe6..3533899 100755 --- a/scripts/deploy-and-verify-link.sh +++ b/scripts/deploy-and-verify-link.sh @@ -154,7 +154,7 @@ if [ "$CONFIRMED" != "true" ]; then echo "2. Transaction may have failed" echo "3. RPC node may be out of sync" echo "" - echo "Check block explorer: https://explorer.d-bis.org/address/$ACCOUNT" + echo "Check block explorer: https://explorer.d-bis.org/addresses/$ACCOUNT" exit 1 fi diff --git a/scripts/deploy-link-token.sh b/scripts/deploy-link-token.sh index 75eb47b..5b1976e 100755 --- a/scripts/deploy-link-token.sh +++ b/scripts/deploy-link-token.sh @@ -255,7 +255,7 @@ if [ -n "$DEPLOYED_ADDRESS" ] && [ "$DEPLOYED_ADDRESS" != "0x0000000000000000000 log_info "Next steps:" log_info "1. Update config/address-inventory.json with LINK token address" log_info "2. Fund bridge contracts: ./scripts/fund-bridge-contracts.sh 10" - log_info "3. Verify contract on explorer: https://explorer.d-bis.org/address/$DEPLOYED_ADDRESS" + log_info "3. Verify contract on explorer: https://explorer.d-bis.org/addresses/$DEPLOYED_ADDRESS" exit 0 else diff --git a/scripts/fix-nginx-conflicts-vmid5000.sh b/scripts/fix-nginx-conflicts-vmid5000.sh index a196292..27a2886 100644 --- a/scripts/fix-nginx-conflicts-vmid5000.sh +++ b/scripts/fix-nginx-conflicts-vmid5000.sh @@ -66,6 +66,36 @@ server { } location = /snap { rewrite ^ /snap/ last; } + # Token-aggregation + static config on HTTP (plain :80) so /api/v1/* never hits Blockscout by mistake + location /api/v1/ { + proxy_pass http://127.0.0.1:3001/api/v1/; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + add_header Access-Control-Allow-Origin *; + } + location = /api/config/token-list { + default_type application/json; + add_header Access-Control-Allow-Origin *; + add_header Cache-Control "public, max-age=3600"; + alias /var/www/html/config/DUAL_CHAIN_TOKEN_LIST.tokenlist.json; + } + location = /api/config/networks { + default_type application/json; + add_header Access-Control-Allow-Origin *; + add_header Cache-Control "public, max-age=3600"; + alias /var/www/html/config/DUAL_CHAIN_NETWORKS.json; + } + location = /api/config/capabilities { + default_type application/json; + add_header Access-Control-Allow-Origin *; + add_header Cache-Control "public, max-age=3600"; + alias /var/www/html/config/CHAIN138_RPC_CAPABILITIES.json; + } + location / { if ($redirect_to_https = 1) { return 301 https://$host$request_uri; } proxy_pass http://127.0.0.1:4000; @@ -112,24 +142,7 @@ server { add_header Cache-Control "no-store, no-cache, must-revalidate"; } - # Blockscout Explorer endpoint - proxy to Blockscout - location / { - proxy_pass http://127.0.0.1:4000; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Connection ""; - proxy_buffering off; - proxy_request_buffering off; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $connection_upgrade; - proxy_read_timeout 300s; - proxy_connect_timeout 75s; - } - - # Token-aggregation API at /api/v1/ (Chain 138 Snap: market data, swap quote, bridge). Service runs on port 3001. + # Token-aggregation API at /api/v1/ — define BEFORE /api/ and / so longest-prefix routing is explicit (Snap + dApps). location /api/v1/ { proxy_pass http://127.0.0.1:3001/api/v1/; proxy_http_version 1.1; @@ -162,7 +175,7 @@ server { alias /var/www/html/config/CHAIN138_RPC_CAPABILITIES.json; } - # API endpoint (for Blockscout API) + # Blockscout API (excludes /api/v1/ which is handled above) location /api/ { proxy_pass http://127.0.0.1:4000; proxy_http_version 1.1; @@ -184,6 +197,23 @@ server { proxy_set_header Host $host; add_header Content-Type application/json; } + + # Blockscout Explorer UI (catch-all) + location / { + proxy_pass http://127.0.0.1:4000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Connection ""; + proxy_buffering off; + proxy_request_buffering off; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + proxy_read_timeout 300s; + proxy_connect_timeout 75s; + } } # WebSocket upgrade mapping diff --git a/scripts/serve-explorer-local.sh b/scripts/serve-explorer-local.sh index 921273a..8f8e0ab 100755 --- a/scripts/serve-explorer-local.sh +++ b/scripts/serve-explorer-local.sh @@ -1,26 +1,16 @@ -#!/bin/bash -# Simple local server for explorer (fallback option) -# Usage: ./serve-explorer-local.sh [port] +#!/usr/bin/env bash +# Local static explorer with SPA path fallback (/institution, /compare, /addresses/… → index.html). +# Usage: SERVE_BIND=0.0.0.0 ./scripts/serve-explorer-local.sh [port] +# Requires: Python 3.7+ -PORT=${1:-8080} -FRONTEND_DIR="$(cd "$(dirname "$0")/../frontend/public" && pwd)" +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PORT="${1:-8080}" +BIND="${SERVE_BIND:-127.0.0.1}" -if [ ! -f "$FRONTEND_DIR/index.html" ]; then - echo "❌ Frontend not found at: $FRONTEND_DIR/index.html" - exit 1 +if ! command -v python3 >/dev/null 2>&1; then + echo "python3 required" >&2 + exit 1 fi -echo "Serving explorer on http://localhost:$PORT" -echo "Frontend: $FRONTEND_DIR" - -cd "$FRONTEND_DIR" - -# Try Python 3 first, then Python 2 -if command -v python3 >/dev/null 2>&1; then - python3 -m http.server "$PORT" -elif command -v python >/dev/null 2>&1; then - python -m SimpleHTTPServer "$PORT" -else - echo "❌ Python not found. Install Python to use this script." - exit 1 -fi +exec python3 "$SCRIPT_DIR/serve_explorer_spa.py" "$PORT" --bind "$BIND" diff --git a/scripts/serve_explorer_spa.py b/scripts/serve_explorer_spa.py new file mode 100755 index 0000000..0c4d63a --- /dev/null +++ b/scripts/serve_explorer_spa.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""Static explorer with SPA fallback: unknown paths serve index.html (client router). +API paths (/api/, /explorer-api/) are not rewritten so missing backends still 404 clearly.""" +from __future__ import annotations + +import argparse +import os +import sys +from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import urlparse + + +def main() -> int: + p = argparse.ArgumentParser(description="Serve explorer-monorepo/frontend/public with SPA fallback.") + p.add_argument("port", nargs="?", type=int, default=8080, help="Listen port (default 8080)") + p.add_argument( + "--bind", + default="127.0.0.1", + help="Bind address (default 127.0.0.1)", + ) + args = p.parse_args() + script_dir = os.path.dirname(os.path.abspath(__file__)) + root = os.path.normpath(os.path.join(script_dir, "..", "frontend", "public")) + if not os.path.isfile(os.path.join(root, "index.html")): + print(f"ERROR: index.html not found under {root}", file=sys.stderr) + return 1 + + class Handler(SimpleHTTPRequestHandler): + def __init__(self, *a, **kw): + super().__init__(*a, directory=root, **kw) + + def do_GET(self) -> None: # noqa: N802 + path = urlparse(self.path).path + if path.startswith("/api/") or path.startswith("/explorer-api"): + return super().do_GET() + rel = path.lstrip("/") + if rel.startswith(".."): + self.send_error(403, "Forbidden") + return + fs = os.path.join(root, rel) if rel else root + if os.path.isfile(fs): + return super().do_GET() + self.path = "/index.html" + return super().do_GET() + + httpd = ThreadingHTTPServer((args.bind, args.port), Handler) + print(f"Serving SPA explorer: http://{args.bind}:{args.port}/ (root={root})", flush=True) + try: + httpd.serve_forever() + except KeyboardInterrupt: + print("\nStopped.", flush=True) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())