diff --git a/frontend/public/explorer-spa.js b/frontend/public/explorer-spa.js index 24c0387..8e85fa6 100644 --- a/frontend/public/explorer-spa.js +++ b/frontend/public/explorer-spa.js @@ -1150,6 +1150,181 @@ }; } + function hexToDecimalString(value) { + if (value == null || value === '') return '0'; + var stringValue = String(value); + if (!/^0x/i.test(stringValue)) return stringValue; + try { + return BigInt(stringValue).toString(); + } catch (e) { + return '0'; + } + } + + function hexToNumber(value) { + if (value == null || value === '') return 0; + if (typeof value === 'number') return value; + try { + return Number(BigInt(String(value))); + } catch (e) { + return Number(value) || 0; + } + } + + function rpcTimestampToIso(value) { + var timestampSeconds = hexToNumber(value); + if (!timestampSeconds) return new Date(0).toISOString(); + return new Date(timestampSeconds * 1000).toISOString(); + } + + function normalizeRpcBlock(block) { + if (!block || !block.hash) return null; + return normalizeBlock({ + number: hexToDecimalString(block.number), + hash: block.hash, + parent_hash: block.parentHash, + timestamp: rpcTimestampToIso(block.timestamp), + miner: block.miner, + transaction_count: Array.isArray(block.transactions) ? block.transactions.length : 0, + gas_used: hexToDecimalString(block.gasUsed), + gas_limit: hexToDecimalString(block.gasLimit), + size: hexToNumber(block.size), + difficulty: hexToDecimalString(block.difficulty), + base_fee_per_gas: block.baseFeePerGas ? hexToDecimalString(block.baseFeePerGas) : undefined, + nonce: block.nonce || '0x0' + }); + } + + function normalizeRpcTransaction(tx, receipt, block) { + if (!tx || !tx.hash) return null; + var blockTimestamp = block && block.timestamp ? rpcTimestampToIso(block.timestamp) : new Date(0).toISOString(); + var txType = tx.type != null ? hexToNumber(tx.type) : 0; + var effectiveGasPrice = receipt && receipt.effectiveGasPrice ? receipt.effectiveGasPrice : tx.gasPrice; + return normalizeTransaction({ + hash: tx.hash, + from: tx.from, + to: tx.to, + value: hexToDecimalString(tx.value), + block_number: tx.blockNumber ? hexToDecimalString(tx.blockNumber) : null, + block_hash: tx.blockHash || null, + transaction_index: tx.transactionIndex ? hexToNumber(tx.transactionIndex) : 0, + gas_price: effectiveGasPrice ? hexToDecimalString(effectiveGasPrice) : '0', + gas_used: receipt && receipt.gasUsed ? hexToDecimalString(receipt.gasUsed) : '0', + gas_limit: tx.gas ? hexToDecimalString(tx.gas) : '0', + nonce: tx.nonce != null ? String(hexToNumber(tx.nonce)) : '0', + status: receipt && receipt.status != null ? hexToNumber(receipt.status) : 0, + created_at: blockTimestamp, + input: tx.input || '0x', + max_fee_per_gas: tx.maxFeePerGas ? hexToDecimalString(tx.maxFeePerGas) : undefined, + max_priority_fee_per_gas: tx.maxPriorityFeePerGas ? hexToDecimalString(tx.maxPriorityFeePerGas) : undefined, + type: txType, + contract_address: receipt && receipt.contractAddress ? receipt.contractAddress : null + }); + } + + async function fetchChain138BlocksPage(page, pageSize) { + try { + var response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/blocks?page=${page}&page_size=${pageSize}`); + if (response && response.items) { + return response.items.map(normalizeBlock).filter(function(block) { return block !== null; }); + } + } catch (error) { + console.warn('Falling back to RPC blocks list:', error.message || error); + } + var blocks = []; + var latestBlockHex = await rpcCall('eth_blockNumber', []); + var latestBlock = hexToNumber(latestBlockHex); + var startIndex = Math.max(0, latestBlock - ((page - 1) * pageSize)); + for (var i = 0; i < pageSize && startIndex - i >= 0; i++) { + var block = await rpcCall('eth_getBlockByNumber', ['0x' + (startIndex - i).toString(16), false]).catch(function() { return null; }); + var normalized = normalizeRpcBlock(block); + if (normalized) blocks.push(normalized); + } + return blocks; + } + + async function fetchChain138TransactionsPage(page, pageSize) { + try { + var response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/transactions?page=${page}&page_size=${pageSize}`); + if (response && response.items) { + return response.items.map(normalizeTransaction).filter(function(tx) { return tx !== null; }); + } + } catch (error) { + console.warn('Falling back to RPC transactions list:', error.message || error); + } + var transactions = []; + var latestBlockHex = await rpcCall('eth_blockNumber', []); + var latestBlock = hexToNumber(latestBlockHex); + var blockCursor = Math.max(0, latestBlock - ((page - 1) * 5)); + while (blockCursor >= 0 && transactions.length < pageSize) { + var block = await rpcCall('eth_getBlockByNumber', ['0x' + blockCursor.toString(16), true]).catch(function() { return null; }); + if (block && Array.isArray(block.transactions)) { + var blockIsoTimestamp = rpcTimestampToIso(block.timestamp); + block.transactions.forEach(function(tx) { + if (transactions.length >= pageSize) return; + var normalized = normalizeTransaction({ + hash: tx.hash, + from: tx.from, + to: tx.to, + value: hexToDecimalString(tx.value), + block_number: tx.blockNumber ? hexToDecimalString(tx.blockNumber) : hexToDecimalString(block.number), + block_hash: tx.blockHash || block.hash, + gas_price: tx.gasPrice ? hexToDecimalString(tx.gasPrice) : (tx.maxFeePerGas ? hexToDecimalString(tx.maxFeePerGas) : '0'), + gas_limit: tx.gas ? hexToDecimalString(tx.gas) : '0', + nonce: tx.nonce != null ? String(hexToNumber(tx.nonce)) : '0', + created_at: blockIsoTimestamp, + input: tx.input || '0x', + max_fee_per_gas: tx.maxFeePerGas ? hexToDecimalString(tx.maxFeePerGas) : undefined, + max_priority_fee_per_gas: tx.maxPriorityFeePerGas ? hexToDecimalString(tx.maxPriorityFeePerGas) : undefined, + type: tx.type != null ? hexToNumber(tx.type) : 0 + }); + if (normalized) transactions.push(normalized); + }); + } + blockCursor -= 1; + } + return transactions; + } + + async function fetchChain138BlockDetail(blockNumber) { + try { + var response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/blocks/${blockNumber}`); + return { + block: normalizeBlock(response), + rawBlockResponse: response + }; + } catch (error) { + console.warn('Falling back to RPC block detail:', error.message || error); + } + var rpcBlock = await rpcCall('eth_getBlockByNumber', ['0x' + Number(blockNumber).toString(16), true]); + return { + block: normalizeRpcBlock(rpcBlock), + rawBlockResponse: null + }; + } + + async function fetchChain138TransactionDetail(txHash) { + try { + var response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/transactions/${txHash}`); + return { + transaction: normalizeTransaction(response), + rawTransaction: response + }; + } catch (error) { + console.warn('Falling back to RPC transaction detail:', error.message || error); + } + var rpcTx = await rpcCall('eth_getTransactionByHash', [txHash]); + if (!rpcTx) { + return { transaction: null, rawTransaction: null }; + } + var receipt = await rpcCall('eth_getTransactionReceipt', [txHash]).catch(function() { return null; }); + var block = rpcTx.blockNumber ? await rpcCall('eth_getBlockByNumber', [rpcTx.blockNumber, false]).catch(function() { return null; }) : null; + return { + transaction: normalizeRpcTransaction(rpcTx, receipt, block), + rawTransaction: null + }; + } + // Skeleton loader function function createSkeletonLoader(type) { switch(type) { @@ -2904,17 +3079,8 @@ let blocks = []; - // For ChainID 138, use Blockscout API if (CHAIN_ID === 138) { - try { - const response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/blocks?page=${blocksListPage}&page_size=${LIST_PAGE_SIZE}`); - if (response && response.items) { - blocks = response.items.map(normalizeBlock).filter(b => b !== null); - } - } catch (error) { - console.error('Failed to load blocks from Blockscout:', error); - throw error; - } + blocks = await fetchChain138BlocksPage(blocksListPage, LIST_PAGE_SIZE); } else { // For other networks, use Etherscan-compatible API const blockData = await fetchAPI(`${API_BASE}?module=block&action=eth_block_number`); @@ -2954,7 +3120,11 @@ } else { filteredBlocks.forEach(function(block) { var d = normalizeBlockDisplay(block); - html += '' + escapeHtml(String(d.blockNum)) + '' + escapeHtml(shortenHash(d.hash)) + '' + escapeHtml(String(d.txCount)) + '' + escapeHtml(d.timestampFormatted) + ''; + var blockNumber = escapeHtml(String(d.blockNum)); + var blockHref = '/block/' + encodeURIComponent(String(d.blockNum)); + var blockLink = '' + blockNumber + ''; + var hashLink = safeBlockNumber(d.blockNum) ? '' + escapeHtml(shortenHash(d.hash)) + '' : '' + escapeHtml(shortenHash(d.hash)) + ''; + html += '' + blockLink + '' + hashLink + '' + escapeHtml(String(d.txCount)) + '' + escapeHtml(d.timestampFormatted) + ''; }); } @@ -2978,17 +3148,8 @@ let transactions = []; - // For ChainID 138, use Blockscout API if (CHAIN_ID === 138) { - try { - const response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/transactions?page=${transactionsListPage}&page_size=${LIST_PAGE_SIZE}`); - if (response && response.items) { - transactions = response.items.map(normalizeTransaction).filter(tx => tx !== null); - } - } catch (error) { - console.error('Failed to load transactions from Blockscout:', error); - throw error; - } + transactions = await fetchChain138TransactionsPage(transactionsListPage, LIST_PAGE_SIZE); } else { // For other networks, use Etherscan-compatible API const blockData = await fetchAPI(`${API_BASE}?module=block&action=eth_block_number`); @@ -3050,9 +3211,13 @@ const value = tx.value || '0'; const blockNumber = tx.block_number || 'N/A'; const valueFormatted = formatEther(value); - var fromClick = safeAddress(from) ? ' onclick="event.stopPropagation(); showAddressDetail(\'' + escapeHtml(from) + '\')" style="cursor: pointer;"' : ''; - var toClick = safeAddress(to) ? ' onclick="event.stopPropagation(); showAddressDetail(\'' + escapeHtml(to || '') + '\')" style="cursor: pointer;"' : ''; - html += '' + escapeHtml(shortenHash(hash)) + '' + formatAddressWithLabel(from) + '' + (to ? formatAddressWithLabel(to) : '-') + '' + escapeHtml(valueFormatted) + ' ETH' + escapeHtml(String(blockNumber)) + ''; + var safeHash = escapeHtml(hash); + var txHref = '/tx/' + encodeURIComponent(hash); + var hashLink = '' + escapeHtml(shortenHash(hash)) + ''; + var fromLink = safeAddress(from) ? '' + formatAddressWithLabel(from) + '' : formatAddressWithLabel(from); + var toLink = safeAddress(to) ? '' + formatAddressWithLabel(to) + '' : (to ? formatAddressWithLabel(to) : '-'); + var blockLink = safeBlockNumber(blockNumber) ? '' + escapeHtml(String(blockNumber)) + '' : escapeHtml(String(blockNumber)); + html += '' + hashLink + '' + fromLink + '' + toLink + '' + escapeHtml(valueFormatted) + ' ETH' + blockLink + ''; }); } @@ -3131,7 +3296,7 @@ var tokenCount = Number(item.token_count || 0); var lastSeen = String(item.last_seen_at || '—'); html += ''; - html += '' + escapeHtml(shortenHash(addr)) + ''; + html += '' + escapeHtml(shortenHash(addr)) + ''; html += '' + escapeHtml(label || '—') + ''; html += '' + escapeHtml(type) + ''; html += '' + escapeHtml(String(txSent)) + ''; @@ -4171,7 +4336,7 @@ if (/^0x0{40}$/i.test(s)) return null; return s; } - async function showBlockDetail(blockNumber) { + async function renderBlockDetail(blockNumber) { const bn = safeBlockNumber(blockNumber); if (!bn) { showToast('Invalid block number', 'error'); return; } blockNumber = bn; @@ -4189,9 +4354,9 @@ var rawBlockResponse = null; if (CHAIN_ID === 138) { try { - const response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/blocks/${blockNumber}`); - rawBlockResponse = response; - b = normalizeBlock(response); + var detailResult = await fetchChain138BlockDetail(blockNumber); + rawBlockResponse = detailResult.rawBlockResponse; + b = detailResult.block; if (!b) { throw new Error('Block not found'); } @@ -4286,10 +4451,10 @@ container.innerHTML = '
Failed to load block: ' + escapeHtml(error.message) + '
'; } } - window._showBlockDetail = showBlockDetail; + window._showBlockDetail = renderBlockDetail; // Keep wrapper (do not overwrite) so all calls go through setTimeout and avoid stack overflow - async function showTransactionDetail(txHash) { + async function renderTransactionDetail(txHash) { const th = safeTxHash(txHash); if (!th) { showToast('Invalid transaction hash', 'error'); return; } txHash = th; @@ -4306,9 +4471,9 @@ if (CHAIN_ID === 138) { try { - const response = await fetchAPIWithRetry(`${BLOCKSCOUT_API}/v2/transactions/${txHash}`); - rawTx = response; - t = normalizeTransaction(response); + var detailResult = await fetchChain138TransactionDetail(txHash); + rawTx = detailResult.rawTransaction; + t = detailResult.transaction; if (!t) throw new Error('Transaction not found'); } catch (error) { container.innerHTML = '
Failed to load transaction: ' + escapeHtml(error.message || 'Unknown error') + '.
'; @@ -4584,8 +4749,9 @@ }).catch(function(e) { showToast('Export failed: ' + (e.message || 'Unknown'), 'error'); }); } function exportBlocksCSV() { - fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/blocks?page=1&page_size=50').then(function(r) { - var items = r.items || r || []; + Promise.resolve(CHAIN_ID === 138 ? fetchChain138BlocksPage(1, 50) : fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/blocks?page=1&page_size=50').then(function(r) { + return (r.items || r || []).map(normalizeBlock).filter(function(block) { return block !== null; }); + })).then(function(items) { var rows = [['Block', 'Hash', 'Transactions', 'Timestamp']]; items.forEach(function(b) { var bn = b.height || b.number || b.block_number; @@ -4601,11 +4767,12 @@ }).catch(function(e) { showToast('Export failed: ' + (e.message || 'Unknown'), 'error'); }); } function exportTransactionsListCSV() { - fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/transactions?page=1&page_size=50').then(function(r) { - var items = r.items || r || []; + Promise.resolve(CHAIN_ID === 138 ? fetchChain138TransactionsPage(1, 50) : fetchAPIWithRetry(BLOCKSCOUT_API + '/v2/transactions?page=1&page_size=50').then(function(r) { + return (r.items || r || []).map(normalizeTransaction).filter(function(tx) { return tx !== null; }); + })).then(function(items) { var rows = [['Hash', 'From', 'To', 'Value', 'Block']]; items.forEach(function(tx) { - var t = normalizeTransaction(tx); + var t = tx && tx.hash ? tx : normalizeTransaction(tx); if (t) rows.push([t.hash || '', t.from || '', t.to || '', t.value || '0', String(t.block_number || '')]); }); var csv = rows.map(function(row) { return row.map(function(cell) { return '"' + String(cell).replace(/"/g, '""') + '"'; }).join(','); }).join('\n'); @@ -4655,10 +4822,10 @@ }).catch(function(e) { showToast('Export failed: ' + (e.message || 'Unknown'), 'error'); }); } window.exportAddressTokenBalancesCSV = exportAddressTokenBalancesCSV; - window._showTransactionDetail = showTransactionDetail; + window._showTransactionDetail = renderTransactionDetail; // Keep wrapper (do not overwrite) so all calls go through setTimeout and avoid stack overflow - async function showAddressDetail(address) { + async function renderAddressDetail(address) { const addr = safeAddress(address); if (!addr) { showToast('Invalid address', 'error'); return; } address = addr; @@ -5167,7 +5334,7 @@ container.innerHTML = '
Failed to load address: ' + escapeHtml(error.message) + '
'; } } - window._showAddressDetail = showAddressDetail; + window._showAddressDetail = renderAddressDetail; // Keep wrapper (do not overwrite) so all calls go through setTimeout and avoid stack overflow async function showTokenDetail(tokenAddress) { diff --git a/scripts/e2e-explorer-frontend.spec.ts b/scripts/e2e-explorer-frontend.spec.ts index 30a8c1a..840ad5c 100644 --- a/scripts/e2e-explorer-frontend.spec.ts +++ b/scripts/e2e-explorer-frontend.spec.ts @@ -50,8 +50,8 @@ test.describe('Explorer Frontend - Nav and Detail Links', () => { test('Address breadcrumb home link returns to root', async ({ page }) => { await page.goto(`${EXPLORER_URL}/address/${ADDRESS_TEST}`, { waitUntil: 'networkidle', timeout: 20000 }) - await page.waitForSelector('#addressDetailBreadcrumb', { state: 'attached', timeout: 15000 }) - const homeLink = page.locator('#addressDetailBreadcrumb a[href="/"]').first() + await page.waitForSelector('#addressDetailView.active #addressDetailBreadcrumb', { state: 'visible', timeout: 15000 }) + const homeLink = page.locator('#addressDetailView.active #addressDetailBreadcrumb a[href="/"]').first() await expect(homeLink).toBeVisible({ timeout: 8000 }) await homeLink.click() await expect(page).toHaveURL(new RegExp(`${EXPLORER_URL.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/?$`), { timeout: 8000 }) @@ -59,29 +59,29 @@ test.describe('Explorer Frontend - Nav and Detail Links', () => { test('Blocks list opens block detail view', async ({ page }) => { await page.goto(`${EXPLORER_URL}/blocks`, { waitUntil: 'networkidle', timeout: 20000 }) - const blockRow = page.locator('#blocksList tbody tr').first() - await expect(blockRow).toBeVisible({ timeout: 8000 }) - await blockRow.click() + const blockLink = page.locator('#blocksList tbody tr:first-child td:first-child a').first() + await expect(blockLink).toBeVisible({ timeout: 8000 }) + await blockLink.click() await expect(page).toHaveURL(/\/block\/\d+/, { timeout: 8000 }) - await expect(page.locator('#blockDetailBreadcrumb')).toBeVisible({ timeout: 8000 }) + await expect(page.locator('#blockDetailView.active #blockDetailBreadcrumb')).toBeVisible({ timeout: 8000 }) }) test('Transactions list opens transaction detail view', async ({ page }) => { await page.goto(`${EXPLORER_URL}/transactions`, { waitUntil: 'networkidle', timeout: 20000 }) - const transactionRow = page.locator('#allTransactions tbody tr').first() - await expect(transactionRow).toBeVisible({ timeout: 8000 }) - await transactionRow.click() + const transactionLink = page.locator('#transactionsList tbody tr:first-child td:first-child a').first() + await expect(transactionLink).toBeVisible({ timeout: 8000 }) + await transactionLink.click() await expect(page).toHaveURL(/\/tx\/0x[a-f0-9]+/i, { timeout: 8000 }) - await expect(page.locator('#transactionDetailBreadcrumb')).toBeVisible({ timeout: 8000 }) + await expect(page.locator('#transactionDetailView.active #transactionDetailBreadcrumb')).toBeVisible({ timeout: 8000 }) }) test('Addresses list opens address detail view', async ({ page }) => { await page.goto(`${EXPLORER_URL}/addresses`, { waitUntil: 'networkidle', timeout: 20000 }) - const addressRow = page.locator('#addressesList tbody tr').first() - await expect(addressRow).toBeVisible({ timeout: 8000 }) - await expect(addressRow).not.toContainText('N/A') - await addressRow.click() + const addressLink = page.locator('#addressesList tbody tr:first-child td:first-child a').first() + await expect(addressLink).toBeVisible({ timeout: 8000 }) + await expect(page.locator('#addressesList tbody tr').first()).not.toContainText('N/A') + await addressLink.click() await expect(page).toHaveURL(/\/address\/0x[a-f0-9]+/i, { timeout: 8000 }) - await expect(page.locator('#addressDetailBreadcrumb')).toBeVisible({ timeout: 8000 }) + await expect(page.locator('#addressDetailView.active #addressDetailBreadcrumb')).toBeVisible({ timeout: 8000 }) }) })