diff --git a/backend/api/rest/stats.go b/backend/api/rest/stats.go index 4c0ce7e..c26b787 100644 --- a/backend/api/rest/stats.go +++ b/backend/api/rest/stats.go @@ -11,6 +11,7 @@ import ( "time" "github.com/explorer/backend/api/freshness" + "github.com/jackc/pgx/v5" ) type explorerStats struct { @@ -34,6 +35,14 @@ type explorerGasPrices struct { type statsQueryFunc = freshness.QueryRowFunc +type statsErrorRow struct { + err error +} + +func (r statsErrorRow) Scan(dest ...any) error { + return r.err +} + func queryNullableFloat64(ctx context.Context, queryRow statsQueryFunc, query string, args ...any) (*float64, error) { var value sql.NullFloat64 if err := queryRow(ctx, query, args...).Scan(&value); err != nil { @@ -191,23 +200,72 @@ func loadExplorerStats(ctx context.Context, chainID int, queryRow statsQueryFunc return stats, nil } +func loadExplorerStatsFallback(ctx context.Context, chainID int, cause error) explorerStats { + rpcURL := strings.TrimSpace(os.Getenv("RPC_URL")) + now := time.Now().UTC() + queryErr := fmt.Errorf("blockscout database unavailable") + if cause != nil { + queryErr = cause + } + queryRow := func(context.Context, string, ...any) pgx.Row { + return statsErrorRow{err: queryErr} + } + + snapshot, completeness, sampling, diagnostics, err := freshness.BuildSnapshot( + ctx, + chainID, + queryRow, + func(ctx context.Context) (*freshness.Reference, error) { + return freshness.ProbeChainHead(ctx, rpcURL) + }, + now, + nil, + nil, + ) + if err != nil { + if sampling.Issues == nil { + sampling.Issues = map[string]string{} + } + sampling.Issues["fallback_freshness"] = err.Error() + } + if sampling.Issues == nil { + sampling.Issues = map[string]string{} + } + if cause != nil { + sampling.Issues["stats_database"] = cause.Error() + } + + stats := explorerStats{ + Freshness: snapshot, + Completeness: completeness, + Sampling: sampling, + Diagnostics: diagnostics, + } + if snapshot.ChainHead.BlockNumber != nil { + stats.LatestBlock = *snapshot.ChainHead.BlockNumber + } + return stats +} + // handleStats handles GET /api/v2/stats func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { writeMethodNotAllowed(w) return } - if !s.requireDB(w) { - return - } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() - stats, err := loadExplorerStats(ctx, s.chainID, s.db.QueryRow) - if err != nil { - writeError(w, http.StatusServiceUnavailable, "service_unavailable", "explorer stats are temporarily unavailable") - return + var stats explorerStats + if s.db == nil { + stats = loadExplorerStatsFallback(ctx, s.chainID, fmt.Errorf("database pool is not configured")) + } else { + var err error + stats, err = loadExplorerStats(ctx, s.chainID, s.db.QueryRow) + if err != nil { + stats = loadExplorerStatsFallback(ctx, s.chainID, err) + } } w.Header().Set("Content-Type", "application/json") diff --git a/backend/api/rest/stats_internal_test.go b/backend/api/rest/stats_internal_test.go index a36127a..381c524 100644 --- a/backend/api/rest/stats_internal_test.go +++ b/backend/api/rest/stats_internal_test.go @@ -136,3 +136,33 @@ func TestLoadExplorerStatsReturnsErrorWhenQueryFails(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), "query total transactions") } + +func TestLoadExplorerStatsFallbackUsesRPCHead(t *testing.T) { + rpc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Method string `json:"method"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + w.Header().Set("Content-Type", "application/json") + switch req.Method { + case "eth_blockNumber": + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x4d2"}`)) + case "eth_getBlockByNumber": + ts := time.Now().Add(-3 * time.Second).Unix() + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"timestamp":"0x` + strconv.FormatInt(ts, 16) + `"}}`)) + default: + http.Error(w, `{"jsonrpc":"2.0","id":1,"error":{"message":"unsupported"}}`, http.StatusBadRequest) + } + })) + defer rpc.Close() + t.Setenv("RPC_URL", rpc.URL) + + stats := loadExplorerStatsFallback(context.Background(), 138, errors.New("database down")) + + require.Equal(t, int64(1234), stats.LatestBlock) + require.NotNil(t, stats.Freshness.ChainHead.BlockNumber) + require.Equal(t, int64(1234), *stats.Freshness.ChainHead.BlockNumber) + require.Equal(t, freshness.CompletenessUnavailable, stats.Completeness.TransactionsFeed) + require.Contains(t, stats.Sampling.Issues, "stats_database") + require.Contains(t, stats.Sampling.Issues["latest_indexed_block"], "database down") +} diff --git a/frontend/libs/frontend-api-client/api-base.test.ts b/frontend/libs/frontend-api-client/api-base.test.ts index b64c95c..30b9072 100644 --- a/frontend/libs/frontend-api-client/api-base.test.ts +++ b/frontend/libs/frontend-api-client/api-base.test.ts @@ -11,6 +11,15 @@ describe('resolveExplorerApiBase', () => { ).toBe('https://blockscout.defi-oracle.io') }) + it('uses browser HTTPS origin when an explicit same-host HTTP value is present', () => { + expect( + resolveExplorerApiBase({ + envValue: 'http://explorer.d-bis.org', + browserOrigin: 'https://explorer.d-bis.org', + }) + ).toBe('https://explorer.d-bis.org') + }) + it('falls back to same-origin in the browser when env is empty', () => { expect( resolveExplorerApiBase({ diff --git a/frontend/libs/frontend-api-client/api-base.ts b/frontend/libs/frontend-api-client/api-base.ts index d6ff0d1..a07ae51 100644 --- a/frontend/libs/frontend-api-client/api-base.ts +++ b/frontend/libs/frontend-api-client/api-base.ts @@ -4,19 +4,35 @@ function normalizeApiBase(value: string | null | undefined): string { return (value || '').trim().replace(/\/$/, '') } +function preferBrowserOriginForSameHost(explicitBase: string, browserOrigin: string): string { + if (!explicitBase || !browserOrigin) return explicitBase + + try { + const explicitUrl = new URL(explicitBase) + const browserUrl = new URL(browserOrigin) + if (explicitUrl.hostname === browserUrl.hostname && explicitUrl.protocol !== browserUrl.protocol) { + return browserOrigin + } + } catch { + return explicitBase + } + + return explicitBase +} + export function resolveExplorerApiBase(options: { envValue?: string | null browserOrigin?: string | null serverFallback?: string } = {}): string { const explicitBase = normalizeApiBase(options.envValue ?? process.env.NEXT_PUBLIC_API_URL ?? '') - if (explicitBase) { - return explicitBase - } - const browserOrigin = normalizeApiBase( options.browserOrigin ?? (typeof window !== 'undefined' ? window.location.origin : '') ) + if (explicitBase) { + return preferBrowserOriginForSameHost(explicitBase, browserOrigin) + } + if (browserOrigin) { return browserOrigin } diff --git a/frontend/public/explorer-spa.js b/frontend/public/explorer-spa.js index e9e2e17..6a22d1e 100644 --- a/frontend/public/explorer-spa.js +++ b/frontend/public/explorer-spa.js @@ -2100,6 +2100,47 @@ } } + async function fetchChain138AddEthereumChainPayload() { + if (window._chain138AddEthereumChainCache) return window._chain138AddEthereumChainCache; + try { + var res = await fetch('/api/v1/config/metamask?chainId=138', { cache: 'no-store' }); + if (res.ok) { + var data = await res.json(); + if (data && data.addEthereumChain) { + window._chain138AddEthereumChainCache = data.addEthereumChain; + return window._chain138AddEthereumChainCache; + } + } + } catch (e) {} + return null; + } + + async function fetchChain138ReportTokenList() { + if (window._chain138TokenListCache) return window._chain138TokenListCache; + try { + var res = await fetch('/api/v1/report/token-list?chainId=138', { cache: 'no-store' }); + if (res.ok) { + var data = await res.json(); + window._chain138TokenListCache = data && Array.isArray(data.tokens) ? data.tokens : []; + return window._chain138TokenListCache; + } + } catch (e) {} + return []; + } + + async function resolveTokenLogoUri(address) { + if (!address) return undefined; + var tokens = await fetchChain138ReportTokenList(); + var normalized = String(address).toLowerCase(); + for (var i = 0; i < tokens.length; i++) { + var token = tokens[i]; + if (token && token.address && String(token.address).toLowerCase() === normalized) { + return token.logoURI || undefined; + } + } + return undefined; + } + async function addTokenToWallet(address, symbol, decimals, name) { if (!address || !/^0x[a-fA-F0-9]{40}$/i.test(address)) { if (typeof showToast === 'function') showToast('Invalid token address', 'error'); @@ -2122,6 +2163,7 @@ if (typeof showToast === 'function') showToast('This token has no symbol on-chain. Add it manually in MetaMask: use this contract address and set symbol to WETH.', 'info'); return; } + var logoURI = await resolveTokenLogoUri(address); var added = await window.ethereum.request({ method: 'wallet_watchAsset', params: { @@ -2131,7 +2173,7 @@ symbol: (useSymbol !== undefined && useSymbol !== null) ? useSymbol : 'TOKEN', decimals: useDecimals, name: useName || undefined, - image: undefined + image: logoURI } } }); @@ -2252,18 +2294,19 @@ // If chain doesn't exist, add it if (switchError.code === 4902) { try { + var chainPayload = await fetchChain138AddEthereumChainPayload(); await window.ethereum.request({ method: 'wallet_addEthereumChain', - params: [{ + params: [chainPayload || { chainId, - chainName: 'Chain 138', + chainName: 'DeFi Oracle Meta Mainnet', nativeCurrency: { - name: 'ETH', + name: 'Ether', symbol: 'ETH', decimals: 18 }, rpcUrls: RPC_URLS.length > 0 ? RPC_URLS : [RPC_URL], - blockExplorerUrls: [window.location.origin || 'https://blockscout.defi-oracle.io'] + blockExplorerUrls: ['https://explorer.d-bis.org', 'https://blockscout.defi-oracle.io'] }], }); } catch (addError) { @@ -5314,17 +5357,26 @@ const WETH9_BRIDGE_138 = '0xcacfd227A040002e49e2e01626363071324f820a'; const WETH10_BRIDGE_138 = '0xe0E93247376aa097dB308B92e6Ba36bA015535D0'; - // Ethereum Mainnet Bridge Contracts - const WETH9_BRIDGE_MAINNET = '0xc9901ce2Ddb6490FAA183645147a87496d8b20B6'; + // Ethereum Mainnet bridge references (relay release + CCIP hubs) + const WETH9_BRIDGE_MAINNET_RELAY = '0xF9A32F37099c582D28b4dE7Fca6eaC1e5259f939'; + const WETH9_BRIDGE_MAINNET_HUB = '0xc9901ce2Ddb6490FAA183645147a87496d8b20B6'; const WETH10_BRIDGE_MAINNET = '0x04E1e22B0D41e99f4275bd40A50480219bc9A223'; + const CW_BRIDGE_MAINNET = '0x2bF74583206A49Be07E0E8A94197C12987AbD7B5'; + const CW_L1_BRIDGE_138 = '0x152ed3e9912161b76bdfd368d0c84b7c31c10de7'; const explorerLinks = { 'BSC (56)': { label: 'BscScan', baseUrl: 'https://bscscan.com/address/' }, + 'BNB Chain (56)': { label: 'BscScan', baseUrl: 'https://bscscan.com/address/' }, 'Polygon (137)': { label: 'PolygonScan', baseUrl: 'https://polygonscan.com/address/' }, 'Avalanche (43114)': { label: 'Avalanche Explorer', baseUrl: 'https://subnets.avax.network/c-chain/address/' }, + 'Avalanche C-Chain (43114)': { label: 'Avalanche Explorer', baseUrl: 'https://subnets.avax.network/c-chain/address/' }, 'Base (8453)': { label: 'BaseScan', baseUrl: 'https://basescan.org/address/' }, 'Arbitrum (42161)': { label: 'Arbiscan', baseUrl: 'https://arbiscan.io/address/' }, + 'Arbitrum One (42161)': { label: 'Arbiscan', baseUrl: 'https://arbiscan.io/address/' }, 'Optimism (10)': { label: 'Optimistic Etherscan', baseUrl: 'https://optimistic.etherscan.io/address/' }, + 'Gnosis (100)': { label: 'GnosisScan', baseUrl: 'https://gnosisscan.io/address/' }, + 'Cronos (25)': { label: 'Cronoscan', baseUrl: 'https://cronoscan.com/address/' }, + 'Celo (42220)': { label: 'CeloScan', baseUrl: 'https://celoscan.io/address/' }, 'Ethereum Mainnet (1)': { label: 'Etherscan', baseUrl: 'https://etherscan.io/address/' } }; function renderExplorerLink(chainLabel, address) { @@ -5334,27 +5386,49 @@ return 'View on ' + escapeHtml(explorer.label) + ''; } - // Bridge routes configuration - const routes = { + // Bridge routes — prefer live token-aggregation catalog; static fallback matches config/bridge-routes-chain138-default.json + var routes = { weth9: { - 'BSC (56)': '0x24293CA562aE1100E60a4640FF49bd656cFf93B4', + 'Ethereum Mainnet (1)': WETH9_BRIDGE_MAINNET_RELAY, + 'BNB Chain (56)': '0x886C6A4ABC064dbf74E7caEc460b7eeC31F1b78C', 'Polygon (137)': '0xF7736443f02913e7e0773052103296CfE1637448', - 'Avalanche (43114)': '0x24293CA562aE1100E60a4640FF49bd656cFf93B4', + 'Avalanche C-Chain (43114)': '0x3f8C409C6072a2B6a4Ff17071927bA70F80c725F', 'Base (8453)': '0x24293CA562aE1100E60a4640FF49bd656cFf93B4', - 'Arbitrum (42161)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c', + 'Arbitrum One (42161)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c', 'Optimism (10)': '0x6e94e53F73893b2a6784Df663920D31043A6dE07', - 'Ethereum Mainnet (1)': WETH9_BRIDGE_MAINNET + 'Gnosis (100)': '0x4ab39b5BaB7b463435209A9039bd40Cf241F5a82', + 'Cronos (25)': '0x3Cc23d086fCcbAe1e5f3FE2bA4A263E1D27d8Cab', + 'Celo (42220)': '0xD3AD6831aacB5386B8A25BB8D8176a6C8a026f04' }, weth10: { - 'BSC (56)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c', + 'Ethereum Mainnet (1)': WETH10_BRIDGE_MAINNET, + 'BNB Chain (56)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c', 'Polygon (137)': '0x0CA60e6f8589c540200daC9D9Cb27BC2e48eE66A', - 'Avalanche (43114)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c', + 'Avalanche C-Chain (43114)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c', 'Base (8453)': '0x937824f2516fa58f25aeAb92E7BFf7D74F463B4c', - 'Arbitrum (42161)': '0x73376eB92c16977B126dB9112936A20Fa0De3442', + 'Arbitrum One (42161)': '0x73376eB92c16977B126dB9112936A20Fa0De3442', 'Optimism (10)': '0x24293CA562aE1100E60a4640FF49bd656cFf93B4', - 'Ethereum Mainnet (1)': WETH10_BRIDGE_MAINNET + 'Gnosis (100)': '0xC15ACdBAC59B3C7Cb4Ea4B3D58334A4b143B4b44', + 'Cronos (25)': '0x73376eB92c16977B126dB9112936A20Fa0De3442', + 'Celo (42220)': '0xa4B9DD039565AeD9641D45b57061f99d9cA6Df08' } }; + var bridgeRoutesSource = 'static-fallback'; + try { + var routesResp = await fetch(TOKEN_AGGREGATION_API_BASE + '/v1/bridge/routes', { credentials: 'omit' }); + if (routesResp.ok) { + var routesPayload = await routesResp.json(); + if (routesPayload && routesPayload.routes && routesPayload.routes.weth9) { + routes = routesPayload.routes; + bridgeRoutesSource = routesPayload.source || 'token-aggregation'; + if (routesPayload.chain138Bridges && routesPayload.chain138Bridges.weth9) { + // no-op: source addresses already set above from canonical constants + } + } + } + } catch (routesErr) { + console.warn('Bridge routes API unavailable, using static catalog', routesErr); + } const sourceBridgeCount = 2; const mainnetBridgeCount = 2; const routeBridgeCount = new Set([ @@ -5372,7 +5446,7 @@
${escapeHtml(bridgeRoutesSource)}.
Both WETH9 and WETH10 can be bridged to other chains using the CCIP bridge contracts: