Fix explorer SPA detail navigation fallback

This commit is contained in:
defiQUG
2026-03-28 13:59:00 -07:00
parent c0eb601a62
commit e54e2f668b
2 changed files with 224 additions and 57 deletions

View File

@@ -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 += '<tr onclick="showBlockDetail(\'' + escapeHtml(String(d.blockNum)) + '\')" style="cursor: pointer;"><td>' + escapeHtml(String(d.blockNum)) + '</td><td class="hash">' + escapeHtml(shortenHash(d.hash)) + '</td><td>' + escapeHtml(String(d.txCount)) + '</td><td>' + escapeHtml(d.timestampFormatted) + '</td></tr>';
var blockNumber = escapeHtml(String(d.blockNum));
var blockHref = '/block/' + encodeURIComponent(String(d.blockNum));
var blockLink = '<a href="' + blockHref + '" onclick="event.preventDefault(); event.stopPropagation(); showBlockDetail(\'' + blockNumber + '\')" style="color: inherit; text-decoration: none; font-weight: 600;">' + blockNumber + '</a>';
var hashLink = safeBlockNumber(d.blockNum) ? '<a class="hash" href="' + blockHref + '" onclick="event.preventDefault(); event.stopPropagation(); showBlockDetail(\'' + blockNumber + '\')" style="color: inherit; text-decoration: none;">' + escapeHtml(shortenHash(d.hash)) + '</a>' : '<span class="hash">' + escapeHtml(shortenHash(d.hash)) + '</span>';
html += '<tr onclick="showBlockDetail(\'' + blockNumber + '\')" style="cursor: pointer;"><td>' + blockLink + '</td><td>' + hashLink + '</td><td>' + escapeHtml(String(d.txCount)) + '</td><td>' + escapeHtml(d.timestampFormatted) + '</td></tr>';
});
}
@@ -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 += '<tr onclick="showTransactionDetail(\'' + escapeHtml(hash) + '\')" style="cursor: pointer;"><td class="hash">' + escapeHtml(shortenHash(hash)) + '</td><td class="hash"' + fromClick + '>' + formatAddressWithLabel(from) + '</td><td class="hash"' + toClick + '>' + (to ? formatAddressWithLabel(to) : '-') + '</td><td>' + escapeHtml(valueFormatted) + ' ETH</td><td>' + escapeHtml(String(blockNumber)) + '</td></tr>';
var safeHash = escapeHtml(hash);
var txHref = '/tx/' + encodeURIComponent(hash);
var hashLink = '<a class="hash" href="' + txHref + '" onclick="event.preventDefault(); event.stopPropagation(); showTransactionDetail(\'' + safeHash + '\')" style="color: inherit; text-decoration: none;">' + escapeHtml(shortenHash(hash)) + '</a>';
var fromLink = safeAddress(from) ? '<a class="hash" href="/address/' + encodeURIComponent(from) + '" onclick="event.preventDefault(); event.stopPropagation(); showAddressDetail(\'' + escapeHtml(from) + '\')" style="color: inherit; text-decoration: none;">' + formatAddressWithLabel(from) + '</a>' : formatAddressWithLabel(from);
var toLink = safeAddress(to) ? '<a class="hash" href="/address/' + encodeURIComponent(to) + '" onclick="event.preventDefault(); event.stopPropagation(); showAddressDetail(\'' + escapeHtml(to || '') + '\')" style="color: inherit; text-decoration: none;">' + formatAddressWithLabel(to) + '</a>' : (to ? formatAddressWithLabel(to) : '-');
var blockLink = safeBlockNumber(blockNumber) ? '<a href="/block/' + encodeURIComponent(String(blockNumber)) + '" onclick="event.preventDefault(); event.stopPropagation(); showBlockDetail(\'' + escapeHtml(String(blockNumber)) + '\')" style="color: inherit; text-decoration: none;">' + escapeHtml(String(blockNumber)) + '</a>' : escapeHtml(String(blockNumber));
html += '<tr onclick="showTransactionDetail(\'' + safeHash + '\')" style="cursor: pointer;"><td>' + hashLink + '</td><td>' + fromLink + '</td><td>' + toLink + '</td><td>' + escapeHtml(valueFormatted) + ' ETH</td><td>' + blockLink + '</td></tr>';
});
}
@@ -3131,7 +3296,7 @@
var tokenCount = Number(item.token_count || 0);
var lastSeen = String(item.last_seen_at || '—');
html += '<tr style="cursor: pointer;" onclick="showAddressDetail(\'' + escapeHtml(addr) + '\')">';
html += '<td class="hash">' + escapeHtml(shortenHash(addr)) + '</td>';
html += '<td><a class="hash" href="/address/' + encodeURIComponent(addr) + '" onclick="event.preventDefault(); event.stopPropagation(); showAddressDetail(\'' + escapeHtml(addr) + '\')" style="color: inherit; text-decoration: none;">' + escapeHtml(shortenHash(addr)) + '</a></td>';
html += '<td>' + escapeHtml(label || '—') + '</td>';
html += '<td>' + escapeHtml(type) + '</td>';
html += '<td>' + escapeHtml(String(txSent)) + '</td>';
@@ -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 = '<div class="error">Failed to load block: ' + escapeHtml(error.message) + '</div>';
}
}
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 = '<div class="error">Failed to load transaction: ' + escapeHtml(error.message || 'Unknown error') + '. <button onclick="showTransactionDetail(\'' + escapeHtml(String(txHash)) + '\')" class="btn btn-primary" style="margin-top: 1rem;">Retry</button></div>';
@@ -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 = '<div class="error">Failed to load address: ' + escapeHtml(error.message) + '</div>';
}
}
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) {

View File

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