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 })
})
})