Use hash to handle blocks and transactions.

This commit is contained in:
Alejandro Cabeza Romero 2025-12-19 10:11:49 +01:00
parent a997139155
commit c3c357d09a
No known key found for this signature in database
GPG Key ID: DA3D14AE478030FD
12 changed files with 111 additions and 96 deletions

View File

@ -1,13 +1,14 @@
from http.client import NOT_FOUND from http.client import NOT_FOUND
from typing import TYPE_CHECKING, AsyncIterator, List from typing import TYPE_CHECKING, AsyncIterator, List
from fastapi import Path, Query from fastapi import Query
from rusty_results import Empty, Option, Some from rusty_results import Empty, Option, Some
from starlette.responses import JSONResponse, Response from starlette.responses import JSONResponse, Response
from api.streams import into_ndjson_stream from api.streams import into_ndjson_stream
from api.v1.serializers.blocks import BlockRead from api.v1.serializers.blocks import BlockRead
from core.api import NBERequest, NDJsonStreamingResponse from core.api import NBERequest, NDJsonStreamingResponse
from core.types import dehexify
from models.block import Block from models.block import Block
if TYPE_CHECKING: if TYPE_CHECKING:
@ -30,8 +31,11 @@ async def stream(request: NBERequest, prefetch_limit: int = Query(0, alias="pref
return NDJsonStreamingResponse(ndjson_blocks_stream) return NDJsonStreamingResponse(ndjson_blocks_stream)
async def get(request: NBERequest, block_id: int = Path(ge=1)) -> Response: async def get(request: NBERequest, block_hash: str) -> Response:
block = await request.app.state.block_repository.get_by_id(block_id) if not block_hash:
return Response(status_code=NOT_FOUND)
block_hash = dehexify(block_hash)
block = await request.app.state.block_repository.get_by_hash(block_hash)
return block.map(lambda _block: JSONResponse(BlockRead.from_block(_block).model_dump(mode="json"))).unwrap_or_else( return block.map(lambda _block: JSONResponse(BlockRead.from_block(_block).model_dump(mode="json"))).unwrap_or_else(
lambda: Response(status_code=NOT_FOUND) lambda: Response(status_code=NOT_FOUND)
) )

View File

@ -4,17 +4,20 @@ from . import blocks, health, index, transactions
def create_v1_router() -> APIRouter: def create_v1_router() -> APIRouter:
"""
Route order must be preserved.
"""
router = APIRouter() router = APIRouter()
router.add_api_route("/", index.index, methods=["GET", "HEAD"]) router.add_api_route("/", index.index, methods=["GET", "HEAD"])
router.add_api_route("/health", health.get, methods=["GET", "HEAD"])
router.add_api_route("/health/stream", health.stream, methods=["GET", "HEAD"]) router.add_api_route("/health/stream", health.stream, methods=["GET", "HEAD"])
router.add_api_route("/health", health.get, methods=["GET", "HEAD"])
router.add_api_route("/transactions/{transaction_id:int}", transactions.get, methods=["GET"])
router.add_api_route("/transactions/stream", transactions.stream, methods=["GET"]) router.add_api_route("/transactions/stream", transactions.stream, methods=["GET"])
router.add_api_route("/transactions/{transaction_hash:str}", transactions.get, methods=["GET"])
router.add_api_route("/blocks/{block_id:int}", blocks.get, methods=["GET"])
router.add_api_route("/blocks/stream", blocks.stream, methods=["GET"]) router.add_api_route("/blocks/stream", blocks.stream, methods=["GET"])
router.add_api_route("/blocks/{block_hash:str}", blocks.get, methods=["GET"])
return router return router

View File

@ -10,7 +10,7 @@ from models.transactions.transaction import Transaction
class TransactionRead(NbeSchema): class TransactionRead(NbeSchema):
id: int id: int
block_id: int block_hash: HexBytes
hash: HexBytes hash: HexBytes
operations: List[Operation] operations: List[Operation]
inputs: List[HexBytes] inputs: List[HexBytes]
@ -23,7 +23,7 @@ class TransactionRead(NbeSchema):
def from_transaction(cls, transaction: Transaction) -> Self: def from_transaction(cls, transaction: Transaction) -> Self:
return cls( return cls(
id=transaction.id, id=transaction.id,
block_id=transaction.block.id, block_hash=transaction.block.hash,
hash=transaction.hash, hash=transaction.hash,
operations=transaction.operations, operations=transaction.operations,
inputs=transaction.inputs, inputs=transaction.inputs,

View File

@ -1,13 +1,14 @@
from http.client import NOT_FOUND from http.client import NOT_FOUND
from typing import TYPE_CHECKING, AsyncIterator, List from typing import TYPE_CHECKING, AsyncIterator, List
from fastapi import Path, Query from fastapi import Query
from rusty_results import Empty, Option, Some from rusty_results import Empty, Option, Some
from starlette.responses import JSONResponse, Response from starlette.responses import JSONResponse, Response
from api.streams import into_ndjson_stream from api.streams import into_ndjson_stream
from api.v1.serializers.transactions import TransactionRead from api.v1.serializers.transactions import TransactionRead
from core.api import NBERequest, NDJsonStreamingResponse from core.api import NBERequest, NDJsonStreamingResponse
from core.types import dehexify
from models.transactions.transaction import Transaction from models.transactions.transaction import Transaction
if TYPE_CHECKING: if TYPE_CHECKING:
@ -36,8 +37,11 @@ async def stream(request: NBERequest, prefetch_limit: int = Query(0, alias="pref
return NDJsonStreamingResponse(ndjson_transactions_stream) return NDJsonStreamingResponse(ndjson_transactions_stream)
async def get(request: NBERequest, transaction_id: int = Path(ge=1)) -> Response: async def get(request: NBERequest, transaction_hash: str) -> Response:
transaction = await request.app.state.transaction_repository.get_by_id(transaction_id) if not transaction_hash:
return Response(status_code=NOT_FOUND)
transaction_hash = dehexify(transaction_hash)
transaction = await request.app.state.transaction_repository.get_by_hash(transaction_hash)
return transaction.map( return transaction.map(
lambda _transaction: JSONResponse(TransactionRead.from_transaction(_transaction).model_dump(mode="json")) lambda _transaction: JSONResponse(TransactionRead.from_transaction(_transaction).model_dump(mode="json"))
).unwrap_or_else(lambda: Response(status_code=NOT_FOUND)) ).unwrap_or_else(lambda: Response(status_code=NOT_FOUND))

View File

@ -7,6 +7,10 @@ def hexify(data: bytes) -> str:
return data.hex() return data.hex()
def dehexify(data: str) -> bytes:
return bytes.fromhex(data)
HexBytes = Annotated[ HexBytes = Annotated[
bytes, bytes,
PlainSerializer(hexify, return_type=str, when_used="json"), PlainSerializer(hexify, return_type=str, when_used="json"),

View File

@ -46,7 +46,7 @@ class BlockRepository:
else: else:
return Empty() return Empty()
async def get_by_hash(self, block_hash: str) -> Option[Block]: async def get_by_hash(self, block_hash: bytes) -> Option[Block]:
statement = select(Block).where(Block.hash == block_hash) statement = select(Block).where(Block.hash == block_hash)
with self.client.session() as session: with self.client.session() as session:

View File

@ -51,7 +51,7 @@ class TransactionRepository:
else: else:
return Empty() return Empty()
async def get_by_hash(self, transaction_hash: str) -> Option[Transaction]: async def get_by_hash(self, transaction_hash: bytes) -> Option[Transaction]:
statement = select(Transaction).where(Transaction.hash == transaction_hash) statement = select(Transaction).where(Transaction.hash == transaction_hash)
with self.client.session() as session: with self.client.session() as session:

View File

@ -15,8 +15,8 @@ export default function BlocksTable() {
const body = bodyRef.current; const body = bodyRef.current;
const counter = countRef.current; const counter = countRef.current;
// 6 columns: ID | Slot | Hash | Parent | Block Root | Transactions // 5 columns: Hash | Slot | Parent | Block Root | Transactions
ensureFixedRowCount(body, 6, TABLE_SIZE); ensureFixedRowCount(body, 5, TABLE_SIZE);
abortRef.current?.abort(); abortRef.current?.abort();
abortRef.current = new AbortController(); abortRef.current = new AbortController();
@ -33,14 +33,14 @@ export default function BlocksTable() {
if (key) seenKeysRef.current.delete(key); if (key) seenKeysRef.current.delete(key);
body.deleteRow(-1); body.deleteRow(-1);
} }
// pad with placeholders to TABLE_SIZE (6 cols) // pad with placeholders to TABLE_SIZE (5 cols)
ensureFixedRowCount(body, 6, TABLE_SIZE); ensureFixedRowCount(body, 5, TABLE_SIZE);
const real = [...body.rows].filter((r) => !r.classList.contains('ph')).length; const real = [...body.rows].filter((r) => !r.classList.contains('ph')).length;
counter.textContent = String(real); counter.textContent = String(real);
}; };
const navigateToBlockDetail = (blockId) => { const navigateToBlockDetail = (blockHash) => {
history.pushState({}, '', PAGE.BLOCK_DETAIL(blockId)); history.pushState({}, '', PAGE.BLOCK_DETAIL(blockHash));
window.dispatchEvent(new PopStateEvent('popstate')); window.dispatchEvent(new PopStateEvent('popstate'));
}; };
@ -48,15 +48,16 @@ export default function BlocksTable() {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.dataset.key = key; tr.dataset.key = key;
// ID (clickable) // Hash (clickable, replaces ID)
const tdId = document.createElement('td'); const tdId = document.createElement('td');
const linkId = document.createElement('a'); const linkId = document.createElement('a');
linkId.className = 'linkish mono'; linkId.className = 'linkish mono';
linkId.href = PAGE.BLOCK_DETAIL(b.id); linkId.href = PAGE.BLOCK_DETAIL(b.hash);
linkId.textContent = String(b.id); linkId.textContent = shortenHex(b.hash);
linkId.title = b.hash;
linkId.addEventListener('click', (e) => { linkId.addEventListener('click', (e) => {
e.preventDefault(); e.preventDefault();
navigateToBlockDetail(b.id); navigateToBlockDetail(b.hash);
}); });
tdId.appendChild(linkId); tdId.appendChild(linkId);
@ -67,21 +68,18 @@ export default function BlocksTable() {
spSlot.textContent = String(b.slot); spSlot.textContent = String(b.slot);
tdSlot.appendChild(spSlot); tdSlot.appendChild(spSlot);
// Hash
const tdHash = document.createElement('td');
const spHash = document.createElement('span');
spHash.className = 'mono';
spHash.title = b.hash;
spHash.textContent = shortenHex(b.hash);
tdHash.appendChild(spHash);
// Parent (block.parent_block_hash) // Parent (block.parent_block_hash)
const tdParent = document.createElement('td'); const tdParent = document.createElement('td');
const spParent = document.createElement('span'); const linkParent = document.createElement('a');
spParent.className = 'mono'; linkParent.className = 'linkish mono';
spParent.title = b.parent; linkParent.href = PAGE.BLOCK_DETAIL(b.parent);
spParent.textContent = shortenHex(b.parent); linkParent.textContent = shortenHex(b.parent);
tdParent.appendChild(spParent); linkParent.title = b.parent;
linkParent.addEventListener('click', (e) => {
e.preventDefault();
navigateToBlockDetail(b.parent, e);
});
tdParent.appendChild(linkParent);
// Block Root // Block Root
const tdRoot = document.createElement('td'); const tdRoot = document.createElement('td');
@ -98,7 +96,7 @@ export default function BlocksTable() {
spCount.textContent = String(b.transactionCount); spCount.textContent = String(b.transactionCount);
tdCount.appendChild(spCount); tdCount.appendChild(spCount);
tr.append(tdId, tdSlot, tdHash, tdParent, tdRoot, tdCount); tr.append(tdId, tdSlot, tdParent, tdRoot, tdCount);
body.insertBefore(tr, body.firstChild); body.insertBefore(tr, body.firstChild);
pruneAndPad(); pruneAndPad();
}; };
@ -165,9 +163,8 @@ export default function BlocksTable() {
h( h(
'colgroup', 'colgroup',
null, null,
h('col', { style: 'width:80px' }), // ID
h('col', { style: 'width:90px' }), // Slot
h('col', { style: 'width:240px' }), // Hash h('col', { style: 'width:240px' }), // Hash
h('col', { style: 'width:90px' }), // Slot
h('col', { style: 'width:240px' }), // Parent h('col', { style: 'width:240px' }), // Parent
h('col', { style: 'width:240px' }), // Block Root h('col', { style: 'width:240px' }), // Block Root
h('col', { style: 'width:120px' }), // Transactions h('col', { style: 'width:120px' }), // Transactions
@ -178,9 +175,8 @@ export default function BlocksTable() {
h( h(
'tr', 'tr',
null, null,
h('th', null, 'ID'),
h('th', null, 'Slot'),
h('th', null, 'Hash'), h('th', null, 'Hash'),
h('th', null, 'Slot'),
h('th', null, 'Parent'), h('th', null, 'Parent'),
h('th', null, 'Block Root'), h('th', null, 'Block Root'),
h('th', null, 'Transactions'), h('th', null, 'Transactions'),

View File

@ -78,6 +78,7 @@ function normalizeTransaction(raw) {
return { return {
id: raw?.id ?? '', id: raw?.id ?? '',
hash: raw?.hash ?? '',
operations: ops, operations: ops,
executionGasPrice: toNumber(raw?.execution_gas_price), executionGasPrice: toNumber(raw?.execution_gas_price),
storageGasPrice: toNumber(raw?.storage_gas_price), storageGasPrice: toNumber(raw?.storage_gas_price),
@ -90,10 +91,10 @@ function normalizeTransaction(raw) {
function buildTransactionRow(tx) { function buildTransactionRow(tx) {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
// ID // Hash (replaces ID)
const tdId = document.createElement('td'); const tdId = document.createElement('td');
tdId.className = 'mono'; tdId.className = 'mono';
tdId.appendChild(createLink(`/transactions/${tx.id}`, String(tx.id), String(tx.id))); tdId.appendChild(createLink(`/transactions/${tx.hash}`, shortenHex(tx.hash), tx.hash));
// Operations (preview) // Operations (preview)
const tdOps = document.createElement('td'); const tdOps = document.createElement('td');
@ -127,7 +128,7 @@ export default function TransactionsTable() {
const body = bodyRef.current; const body = bodyRef.current;
const counter = countRef.current; const counter = countRef.current;
// 4 columns: ID | Operations | Outputs | Gas // 4 columns: Hash | Operations | Outputs | Gas
ensureFixedRowCount(body, 4, TABLE_SIZE); ensureFixedRowCount(body, 4, TABLE_SIZE);
abortRef.current?.abort(); abortRef.current?.abort();
@ -183,7 +184,7 @@ export default function TransactionsTable() {
h( h(
'colgroup', 'colgroup',
null, null,
h('col', { style: 'width:120px' }), // ID h('col', { style: 'width:240px' }), // Hash
h('col', null), // Operations h('col', null), // Operations
h('col', { style: 'width:200px' }), // Outputs (count / total) h('col', { style: 'width:200px' }), // Outputs (count / total)
h('col', { style: 'width:200px' }), // Gas (execution / storage) h('col', { style: 'width:200px' }), // Gas (execution / storage)
@ -194,7 +195,7 @@ export default function TransactionsTable() {
h( h(
'tr', 'tr',
null, null,
h('th', null, 'ID'), h('th', null, 'Hash'),
h('th', null, 'Operations'), h('th', null, 'Operations'),
h('th', null, 'Outputs (count / total)'), h('th', null, 'Outputs (count / total)'),
h('th', null, 'Gas (execution / storage)'), h('th', null, 'Gas (execution / storage)'),

View File

@ -1,26 +1,26 @@
const API_PREFIX = '/api/v1'; const API_PREFIX = '/api/v1';
const joinUrl = (...parts) => parts.join('/').replace(/\/{2,}/g, '/'); const joinUrl = (...parts) => parts.join('/').replace(/\/{2,}/g, '/');
const encodeId = (id) => encodeURIComponent(String(id)); const encodeHash = (hash) => encodeURIComponent(String(hash));
const HEALTH_ENDPOINT = joinUrl(API_PREFIX, 'health/stream'); const HEALTH_ENDPOINT = joinUrl(API_PREFIX, 'health/stream');
const TRANSACTION_DETAIL_BY_ID = (id) => joinUrl(API_PREFIX, 'transactions', encodeId(id)); const TRANSACTION_DETAIL_BY_HASH = (hash) => joinUrl(API_PREFIX, 'transactions', encodeHash(hash));
const TRANSACTIONS_STREAM = joinUrl(API_PREFIX, 'transactions/stream'); const TRANSACTIONS_STREAM = joinUrl(API_PREFIX, 'transactions/stream');
const BLOCK_DETAIL_BY_ID = (id) => joinUrl(API_PREFIX, 'blocks', encodeId(id)); const BLOCK_DETAIL_BY_HASH = (hash) => joinUrl(API_PREFIX, 'blocks', encodeHash(hash));
const BLOCKS_STREAM = joinUrl(API_PREFIX, 'blocks/stream'); const BLOCKS_STREAM = joinUrl(API_PREFIX, 'blocks/stream');
export const API = { export const API = {
HEALTH_ENDPOINT, HEALTH_ENDPOINT,
TRANSACTION_DETAIL_BY_ID, TRANSACTION_DETAIL_BY_HASH,
TRANSACTIONS_STREAM, TRANSACTIONS_STREAM,
BLOCK_DETAIL_BY_ID, BLOCK_DETAIL_BY_HASH,
BLOCKS_STREAM, BLOCKS_STREAM,
}; };
const BLOCK_DETAIL = (id) => joinUrl('/blocks', encodeId(id)); const BLOCK_DETAIL = (hash) => joinUrl('/blocks', encodeHash(hash));
const TRANSACTION_DETAIL = (id) => joinUrl('/transactions', encodeId(id)); const TRANSACTION_DETAIL = (hash) => joinUrl('/transactions', encodeHash(hash));
export const PAGE = { export const PAGE = {
BLOCK_DETAIL, BLOCK_DETAIL,

View File

@ -2,6 +2,7 @@
import { h, Fragment } from 'preact'; import { h, Fragment } from 'preact';
import { useEffect, useMemo, useState } from 'preact/hooks'; import { useEffect, useMemo, useState } from 'preact/hooks';
import { API, PAGE } from '../lib/api.js'; import { API, PAGE } from '../lib/api.js';
import { shortenHex } from '../lib/utils.js';
const OPERATIONS_PREVIEW_LIMIT = 2; const OPERATIONS_PREVIEW_LIMIT = 2;
@ -73,15 +74,14 @@ function CopyPill({ text }) {
} }
export default function BlockDetailPage({ parameters }) { export default function BlockDetailPage({ parameters }) {
const blockIdParameter = parameters[0]; const blockHash = parameters[0];
const blockId = Number.parseInt(String(blockIdParameter), 10); const isValidHash = typeof blockHash === 'string' && blockHash.length > 0;
const isValidId = Number.isInteger(blockId) && blockId >= 0;
const [block, setBlock] = useState(null); const [block, setBlock] = useState(null);
const [errorMessage, setErrorMessage] = useState(''); const [errorMessage, setErrorMessage] = useState('');
const [errorKind, setErrorKind] = useState(null); // 'invalid-id' | 'not-found' | 'network' | null const [errorKind, setErrorKind] = useState(null); // 'invalid-hash' | 'not-found' | 'network' | null
const pageTitle = useMemo(() => `Block ${String(blockIdParameter)}`, [blockIdParameter]); const pageTitle = useMemo(() => `Block ${shortenHex(blockHash)}`, [blockHash]);
useEffect(() => { useEffect(() => {
document.title = pageTitle; document.title = pageTitle;
}, [pageTitle]); }, [pageTitle]);
@ -91,9 +91,9 @@ export default function BlockDetailPage({ parameters }) {
setErrorMessage(''); setErrorMessage('');
setErrorKind(null); setErrorKind(null);
if (!isValidId) { if (!isValidHash) {
setErrorKind('invalid-id'); setErrorKind('invalid-hash');
setErrorMessage('Invalid block id.'); setErrorMessage('Invalid block hash.');
return; return;
} }
@ -102,7 +102,7 @@ export default function BlockDetailPage({ parameters }) {
(async () => { (async () => {
try { try {
const res = await fetch(API.BLOCK_DETAIL_BY_ID(blockId), { const res = await fetch(API.BLOCK_DETAIL_BY_HASH(blockHash), {
cache: 'no-cache', cache: 'no-cache',
signal: controller.signal, signal: controller.signal,
}); });
@ -127,7 +127,7 @@ export default function BlockDetailPage({ parameters }) {
alive = false; alive = false;
controller.abort(); controller.abort();
}; };
}, [blockId, isValidId]); }, [blockHash, isValidHash]);
const header = block?.header ?? {}; // back-compat only const header = block?.header ?? {}; // back-compat only
const transactions = Array.isArray(block?.transactions) ? block.transactions : []; const transactions = Array.isArray(block?.transactions) ? block.transactions : [];
@ -135,8 +135,7 @@ export default function BlockDetailPage({ parameters }) {
// Prefer new top-level fields; fallback to legacy header.* // Prefer new top-level fields; fallback to legacy header.*
const slot = block?.slot ?? header?.slot ?? null; const slot = block?.slot ?? header?.slot ?? null;
const blockRoot = block?.block_root ?? header?.block_root ?? ''; const blockRoot = block?.block_root ?? header?.block_root ?? '';
const blockHash = block?.hash ?? header?.hash ?? ''; const currentBlockHash = block?.hash ?? header?.hash ?? '';
const parentId = block?.parent_id ?? null;
const parentHash = block?.parent_block_hash ?? header?.parent_block ?? ''; const parentHash = block?.parent_block_hash ?? header?.parent_block ?? '';
return h( return h(
@ -152,7 +151,7 @@ export default function BlockDetailPage({ parameters }) {
), ),
// Error states // Error states
errorKind === 'invalid-id' && h('p', { style: 'color:#ff8a8a' }, errorMessage), errorKind === 'invalid-hash' && h('p', { style: 'color:#ff8a8a' }, errorMessage),
errorKind === 'not-found' && errorKind === 'not-found' &&
h( h(
'div', 'div',
@ -202,12 +201,12 @@ export default function BlockDetailPage({ parameters }) {
'span', 'span',
{ {
class: 'pill mono', class: 'pill mono',
title: blockHash, title: currentBlockHash,
style: 'max-width:100%; overflow-wrap:anywhere; word-break:break-word;', style: 'max-width:100%; overflow-wrap:anywhere; word-break:break-word;',
}, },
String(blockHash), String(currentBlockHash),
), ),
h(CopyPill, { text: blockHash }), h(CopyPill, { text: currentBlockHash }),
), ),
// Root (pill + copy) // Root (pill + copy)
@ -227,32 +226,32 @@ export default function BlockDetailPage({ parameters }) {
h(CopyPill, { text: blockRoot }), h(CopyPill, { text: blockRoot }),
), ),
// Parent (id link OR parent hash) + copy // Parent (parent hash link) + copy
h('div', null, h('b', null, 'Parent:')), h('div', null, h('b', null, 'Parent:')),
h( h(
'div', 'div',
{ style: 'display:flex; gap:8px; flex-wrap:wrap; align-items:flex-start;' }, { style: 'display:flex; gap:8px; flex-wrap:wrap; align-items:flex-start;' },
parentId != null parentHash
? h( ? h(
'a', 'a',
{ {
class: 'pill mono linkish', class: 'pill mono linkish',
href: PAGE.BLOCK_DETAIL(parentId), href: PAGE.BLOCK_DETAIL(parentHash),
title: String(parentId), title: String(parentHash),
style: 'max-width:100%; overflow-wrap:anywhere; word-break:break-word;', style: 'max-width:100%; overflow-wrap:anywhere; word-break:break-word;',
}, },
String(parentId), shortenHex(parentHash),
) )
: h( : h(
'span', 'span',
{ {
class: 'pill mono', class: 'pill mono',
title: parentHash, title: '—',
style: 'max-width:100%; overflow-wrap:anywhere; word-break:break-word;', style: 'max-width:100%; overflow-wrap:anywhere; word-break:break-word;',
}, },
String(parentHash || '—'), '—',
), ),
h(CopyPill, { text: parentId ?? parentHash }), h(CopyPill, { text: parentHash }),
), ),
), ),
), ),
@ -282,7 +281,11 @@ export default function BlockDetailPage({ parameters }) {
h( h(
'tr', 'tr',
null, null,
h('th', { style: 'text-align:left; padding:8px 10px; white-space:nowrap;' }, 'ID'), h(
'th',
{ style: 'text-align:left; padding:8px 10px; white-space:nowrap;' },
'Hash',
),
h( h(
'th', 'th',
{ style: 'text-align:center; padding:8px 10px; white-space:nowrap;' }, { style: 'text-align:center; padding:8px 10px; white-space:nowrap;' },
@ -311,8 +314,8 @@ export default function BlockDetailPage({ parameters }) {
return h( return h(
'tr', 'tr',
{ key: t?.id ?? `${count}/${total}` }, { key: t?.hash ?? `${count}/${total}` },
// ID (left) // Hash (left)
h( h(
'td', 'td',
{ style: 'text-align:left; padding:8px 10px; white-space:nowrap;' }, { style: 'text-align:left; padding:8px 10px; white-space:nowrap;' },
@ -320,10 +323,10 @@ export default function BlockDetailPage({ parameters }) {
'a', 'a',
{ {
class: 'linkish mono', class: 'linkish mono',
href: PAGE.TRANSACTION_DETAIL(t?.id ?? ''), href: PAGE.TRANSACTION_DETAIL(t?.hash ?? ''),
title: String(t?.id ?? ''), title: String(t?.hash ?? ''),
}, },
String(t?.id ?? ''), shortenHex(t?.hash ?? ''),
), ),
), ),
// Outputs (center) // Outputs (center)

View File

@ -1,7 +1,8 @@
// static/pages/TransactionDetail.js // static/pages/TransactionDetail.js
import { h, Fragment } from 'preact'; import { h, Fragment } from 'preact';
import { useEffect, useMemo, useState } from 'preact/hooks'; import { useEffect, useMemo, useState } from 'preact/hooks';
import { API } from '../lib/api.js'; import { API, PAGE } from '../lib/api.js';
import { shortenHex } from '../lib/utils.js';
// ————— helpers ————— // ————— helpers —————
const isNumber = (v) => typeof v === 'number' && !Number.isNaN(v); const isNumber = (v) => typeof v === 'number' && !Number.isNaN(v);
@ -105,7 +106,7 @@ function normalizeTransaction(raw) {
return { return {
id: raw?.id ?? '', id: raw?.id ?? '',
blockId: raw?.block_id ?? null, blockHash: raw?.block_hash ?? null,
hash: renderBytes(raw?.hash), hash: renderBytes(raw?.hash),
proof: renderBytes(raw?.proof), proof: renderBytes(raw?.proof),
operations: ops, // keep objects, well label in UI operations: ops, // keep objects, well label in UI
@ -143,15 +144,15 @@ function Summary({ tx }) {
{ style: 'display:grid; gap:8px;' }, { style: 'display:grid; gap:8px;' },
// Block link // Block link
tx.blockId != null && tx.blockHash != null &&
h( h(
'div', 'div',
null, null,
h('b', null, 'Block: '), h('b', null, 'Block: '),
h( h(
'a', 'a',
{ class: 'linkish mono', href: API.BLOCK_DETAIL_BY_ID(tx.blockId), title: String(tx.blockId) }, { class: 'linkish mono', href: PAGE.BLOCK_DETAIL(tx.blockHash), title: String(tx.blockHash) },
String(tx.blockId), shortenHex(tx.blockHash),
), ),
), ),
@ -349,14 +350,13 @@ function Ledger({ ledger }) {
// ————— page ————— // ————— page —————
export default function TransactionDetail({ parameters }) { export default function TransactionDetail({ parameters }) {
const idParam = parameters?.[0]; const transactionHash = parameters?.[0];
const id = Number.parseInt(String(idParam), 10); const isValidHash = typeof transactionHash === 'string' && transactionHash.length > 0;
const isValidId = Number.isInteger(id) && id >= 0;
const [tx, setTx] = useState(null); const [tx, setTx] = useState(null);
const [err, setErr] = useState(null); // { kind: 'invalid'|'not-found'|'network', msg: string } const [err, setErr] = useState(null); // { kind: 'invalid'|'not-found'|'network', msg: string }
const pageTitle = useMemo(() => `Transaction ${String(idParam)}`, [idParam]); const pageTitle = useMemo(() => `Transaction ${shortenHex(transactionHash)}`, [transactionHash]);
useEffect(() => { useEffect(() => {
document.title = pageTitle; document.title = pageTitle;
}, [pageTitle]); }, [pageTitle]);
@ -365,8 +365,8 @@ export default function TransactionDetail({ parameters }) {
setTx(null); setTx(null);
setErr(null); setErr(null);
if (!isValidId) { if (!isValidHash) {
setErr({ kind: 'invalid', msg: 'Invalid transaction id.' }); setErr({ kind: 'invalid', msg: 'Invalid transaction hash.' });
return; return;
} }
@ -375,7 +375,7 @@ export default function TransactionDetail({ parameters }) {
(async () => { (async () => {
try { try {
const res = await fetch(API.TRANSACTION_DETAIL_BY_ID(id), { const res = await fetch(API.TRANSACTION_DETAIL_BY_HASH(transactionHash), {
cache: 'no-cache', cache: 'no-cache',
signal: controller.signal, signal: controller.signal,
}); });
@ -397,7 +397,7 @@ export default function TransactionDetail({ parameters }) {
alive = false; alive = false;
controller.abort(); controller.abort();
}; };
}, [id, isValidId]); }, [transactionHash, isValidHash]);
return h( return h(
'main', 'main',