From c3c357d09a54977566c01fe0e46cc96d11bae307 Mon Sep 17 00:00:00 2001 From: Alejandro Cabeza Romero Date: Fri, 19 Dec 2025 10:11:49 +0100 Subject: [PATCH] Use hash to handle blocks and transactions. --- src/api/v1/blocks.py | 10 ++-- src/api/v1/router.py | 9 ++-- src/api/v1/serializers/transactions.py | 4 +- src/api/v1/transactions.py | 10 ++-- src/core/types.py | 4 ++ src/db/blocks.py | 2 +- src/db/transaction.py | 2 +- static/components/BlocksTable.js | 52 ++++++++++----------- static/components/TransactionsTable.js | 11 +++-- static/lib/api.js | 14 +++--- static/pages/BlockDetail.js | 63 ++++++++++++++------------ static/pages/TransactionDetail.js | 26 +++++------ 12 files changed, 111 insertions(+), 96 deletions(-) diff --git a/src/api/v1/blocks.py b/src/api/v1/blocks.py index fd9d18f..e28f9c2 100644 --- a/src/api/v1/blocks.py +++ b/src/api/v1/blocks.py @@ -1,13 +1,14 @@ from http.client import NOT_FOUND from typing import TYPE_CHECKING, AsyncIterator, List -from fastapi import Path, Query +from fastapi import Query from rusty_results import Empty, Option, Some from starlette.responses import JSONResponse, Response from api.streams import into_ndjson_stream from api.v1.serializers.blocks import BlockRead from core.api import NBERequest, NDJsonStreamingResponse +from core.types import dehexify from models.block import Block if TYPE_CHECKING: @@ -30,8 +31,11 @@ async def stream(request: NBERequest, prefetch_limit: int = Query(0, alias="pref return NDJsonStreamingResponse(ndjson_blocks_stream) -async def get(request: NBERequest, block_id: int = Path(ge=1)) -> Response: - block = await request.app.state.block_repository.get_by_id(block_id) +async def get(request: NBERequest, block_hash: str) -> Response: + 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( lambda: Response(status_code=NOT_FOUND) ) diff --git a/src/api/v1/router.py b/src/api/v1/router.py index f9a2b37..c7b8c52 100644 --- a/src/api/v1/router.py +++ b/src/api/v1/router.py @@ -4,17 +4,20 @@ from . import blocks, health, index, transactions def create_v1_router() -> APIRouter: + """ + Route order must be preserved. + """ router = APIRouter() 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", 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/{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/{block_hash:str}", blocks.get, methods=["GET"]) return router diff --git a/src/api/v1/serializers/transactions.py b/src/api/v1/serializers/transactions.py index 449551e..c6227e5 100644 --- a/src/api/v1/serializers/transactions.py +++ b/src/api/v1/serializers/transactions.py @@ -10,7 +10,7 @@ from models.transactions.transaction import Transaction class TransactionRead(NbeSchema): id: int - block_id: int + block_hash: HexBytes hash: HexBytes operations: List[Operation] inputs: List[HexBytes] @@ -23,7 +23,7 @@ class TransactionRead(NbeSchema): def from_transaction(cls, transaction: Transaction) -> Self: return cls( id=transaction.id, - block_id=transaction.block.id, + block_hash=transaction.block.hash, hash=transaction.hash, operations=transaction.operations, inputs=transaction.inputs, diff --git a/src/api/v1/transactions.py b/src/api/v1/transactions.py index 3f11921..1a00578 100644 --- a/src/api/v1/transactions.py +++ b/src/api/v1/transactions.py @@ -1,13 +1,14 @@ from http.client import NOT_FOUND from typing import TYPE_CHECKING, AsyncIterator, List -from fastapi import Path, Query +from fastapi import Query from rusty_results import Empty, Option, Some from starlette.responses import JSONResponse, Response from api.streams import into_ndjson_stream from api.v1.serializers.transactions import TransactionRead from core.api import NBERequest, NDJsonStreamingResponse +from core.types import dehexify from models.transactions.transaction import Transaction if TYPE_CHECKING: @@ -36,8 +37,11 @@ async def stream(request: NBERequest, prefetch_limit: int = Query(0, alias="pref return NDJsonStreamingResponse(ndjson_transactions_stream) -async def get(request: NBERequest, transaction_id: int = Path(ge=1)) -> Response: - transaction = await request.app.state.transaction_repository.get_by_id(transaction_id) +async def get(request: NBERequest, transaction_hash: str) -> Response: + 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( lambda _transaction: JSONResponse(TransactionRead.from_transaction(_transaction).model_dump(mode="json")) ).unwrap_or_else(lambda: Response(status_code=NOT_FOUND)) diff --git a/src/core/types.py b/src/core/types.py index bcad3dc..5646d87 100644 --- a/src/core/types.py +++ b/src/core/types.py @@ -7,6 +7,10 @@ def hexify(data: bytes) -> str: return data.hex() +def dehexify(data: str) -> bytes: + return bytes.fromhex(data) + + HexBytes = Annotated[ bytes, PlainSerializer(hexify, return_type=str, when_used="json"), diff --git a/src/db/blocks.py b/src/db/blocks.py index 35b1f2f..5071a1d 100644 --- a/src/db/blocks.py +++ b/src/db/blocks.py @@ -46,7 +46,7 @@ class BlockRepository: else: 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) with self.client.session() as session: diff --git a/src/db/transaction.py b/src/db/transaction.py index fa1d8d7..667cd5b 100644 --- a/src/db/transaction.py +++ b/src/db/transaction.py @@ -51,7 +51,7 @@ class TransactionRepository: else: 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) with self.client.session() as session: diff --git a/static/components/BlocksTable.js b/static/components/BlocksTable.js index afc3345..c79b159 100644 --- a/static/components/BlocksTable.js +++ b/static/components/BlocksTable.js @@ -15,8 +15,8 @@ export default function BlocksTable() { const body = bodyRef.current; const counter = countRef.current; - // 6 columns: ID | Slot | Hash | Parent | Block Root | Transactions - ensureFixedRowCount(body, 6, TABLE_SIZE); + // 5 columns: Hash | Slot | Parent | Block Root | Transactions + ensureFixedRowCount(body, 5, TABLE_SIZE); abortRef.current?.abort(); abortRef.current = new AbortController(); @@ -33,14 +33,14 @@ export default function BlocksTable() { if (key) seenKeysRef.current.delete(key); body.deleteRow(-1); } - // pad with placeholders to TABLE_SIZE (6 cols) - ensureFixedRowCount(body, 6, TABLE_SIZE); + // pad with placeholders to TABLE_SIZE (5 cols) + ensureFixedRowCount(body, 5, TABLE_SIZE); const real = [...body.rows].filter((r) => !r.classList.contains('ph')).length; counter.textContent = String(real); }; - const navigateToBlockDetail = (blockId) => { - history.pushState({}, '', PAGE.BLOCK_DETAIL(blockId)); + const navigateToBlockDetail = (blockHash) => { + history.pushState({}, '', PAGE.BLOCK_DETAIL(blockHash)); window.dispatchEvent(new PopStateEvent('popstate')); }; @@ -48,15 +48,16 @@ export default function BlocksTable() { const tr = document.createElement('tr'); tr.dataset.key = key; - // ID (clickable) + // Hash (clickable, replaces ID) const tdId = document.createElement('td'); const linkId = document.createElement('a'); linkId.className = 'linkish mono'; - linkId.href = PAGE.BLOCK_DETAIL(b.id); - linkId.textContent = String(b.id); + linkId.href = PAGE.BLOCK_DETAIL(b.hash); + linkId.textContent = shortenHex(b.hash); + linkId.title = b.hash; linkId.addEventListener('click', (e) => { e.preventDefault(); - navigateToBlockDetail(b.id); + navigateToBlockDetail(b.hash); }); tdId.appendChild(linkId); @@ -67,21 +68,18 @@ export default function BlocksTable() { spSlot.textContent = String(b.slot); 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) const tdParent = document.createElement('td'); - const spParent = document.createElement('span'); - spParent.className = 'mono'; - spParent.title = b.parent; - spParent.textContent = shortenHex(b.parent); - tdParent.appendChild(spParent); + const linkParent = document.createElement('a'); + linkParent.className = 'linkish mono'; + linkParent.href = PAGE.BLOCK_DETAIL(b.parent); + linkParent.textContent = shortenHex(b.parent); + linkParent.title = b.parent; + linkParent.addEventListener('click', (e) => { + e.preventDefault(); + navigateToBlockDetail(b.parent, e); + }); + tdParent.appendChild(linkParent); // Block Root const tdRoot = document.createElement('td'); @@ -98,7 +96,7 @@ export default function BlocksTable() { spCount.textContent = String(b.transactionCount); tdCount.appendChild(spCount); - tr.append(tdId, tdSlot, tdHash, tdParent, tdRoot, tdCount); + tr.append(tdId, tdSlot, tdParent, tdRoot, tdCount); body.insertBefore(tr, body.firstChild); pruneAndPad(); }; @@ -165,9 +163,8 @@ export default function BlocksTable() { h( 'colgroup', null, - h('col', { style: 'width:80px' }), // ID - h('col', { style: 'width:90px' }), // Slot h('col', { style: 'width:240px' }), // Hash + h('col', { style: 'width:90px' }), // Slot h('col', { style: 'width:240px' }), // Parent h('col', { style: 'width:240px' }), // Block Root h('col', { style: 'width:120px' }), // Transactions @@ -178,9 +175,8 @@ export default function BlocksTable() { h( 'tr', null, - h('th', null, 'ID'), - h('th', null, 'Slot'), h('th', null, 'Hash'), + h('th', null, 'Slot'), h('th', null, 'Parent'), h('th', null, 'Block Root'), h('th', null, 'Transactions'), diff --git a/static/components/TransactionsTable.js b/static/components/TransactionsTable.js index 5b9216f..3d67ce5 100644 --- a/static/components/TransactionsTable.js +++ b/static/components/TransactionsTable.js @@ -78,6 +78,7 @@ function normalizeTransaction(raw) { return { id: raw?.id ?? '', + hash: raw?.hash ?? '', operations: ops, executionGasPrice: toNumber(raw?.execution_gas_price), storageGasPrice: toNumber(raw?.storage_gas_price), @@ -90,10 +91,10 @@ function normalizeTransaction(raw) { function buildTransactionRow(tx) { const tr = document.createElement('tr'); - // ID + // Hash (replaces ID) const tdId = document.createElement('td'); 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) const tdOps = document.createElement('td'); @@ -127,7 +128,7 @@ export default function TransactionsTable() { const body = bodyRef.current; const counter = countRef.current; - // 4 columns: ID | Operations | Outputs | Gas + // 4 columns: Hash | Operations | Outputs | Gas ensureFixedRowCount(body, 4, TABLE_SIZE); abortRef.current?.abort(); @@ -183,7 +184,7 @@ export default function TransactionsTable() { h( 'colgroup', null, - h('col', { style: 'width:120px' }), // ID + h('col', { style: 'width:240px' }), // Hash h('col', null), // Operations h('col', { style: 'width:200px' }), // Outputs (count / total) h('col', { style: 'width:200px' }), // Gas (execution / storage) @@ -194,7 +195,7 @@ export default function TransactionsTable() { h( 'tr', null, - h('th', null, 'ID'), + h('th', null, 'Hash'), h('th', null, 'Operations'), h('th', null, 'Outputs (count / total)'), h('th', null, 'Gas (execution / storage)'), diff --git a/static/lib/api.js b/static/lib/api.js index 0b36f2f..56ce0af 100644 --- a/static/lib/api.js +++ b/static/lib/api.js @@ -1,26 +1,26 @@ const API_PREFIX = '/api/v1'; 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 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 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'); export const API = { HEALTH_ENDPOINT, - TRANSACTION_DETAIL_BY_ID, + TRANSACTION_DETAIL_BY_HASH, TRANSACTIONS_STREAM, - BLOCK_DETAIL_BY_ID, + BLOCK_DETAIL_BY_HASH, BLOCKS_STREAM, }; -const BLOCK_DETAIL = (id) => joinUrl('/blocks', encodeId(id)); -const TRANSACTION_DETAIL = (id) => joinUrl('/transactions', encodeId(id)); +const BLOCK_DETAIL = (hash) => joinUrl('/blocks', encodeHash(hash)); +const TRANSACTION_DETAIL = (hash) => joinUrl('/transactions', encodeHash(hash)); export const PAGE = { BLOCK_DETAIL, diff --git a/static/pages/BlockDetail.js b/static/pages/BlockDetail.js index 7df1490..cfaa6e3 100644 --- a/static/pages/BlockDetail.js +++ b/static/pages/BlockDetail.js @@ -2,6 +2,7 @@ import { h, Fragment } from 'preact'; import { useEffect, useMemo, useState } from 'preact/hooks'; import { API, PAGE } from '../lib/api.js'; +import { shortenHex } from '../lib/utils.js'; const OPERATIONS_PREVIEW_LIMIT = 2; @@ -73,15 +74,14 @@ function CopyPill({ text }) { } export default function BlockDetailPage({ parameters }) { - const blockIdParameter = parameters[0]; - const blockId = Number.parseInt(String(blockIdParameter), 10); - const isValidId = Number.isInteger(blockId) && blockId >= 0; + const blockHash = parameters[0]; + const isValidHash = typeof blockHash === 'string' && blockHash.length > 0; const [block, setBlock] = useState(null); 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(() => { document.title = pageTitle; }, [pageTitle]); @@ -91,9 +91,9 @@ export default function BlockDetailPage({ parameters }) { setErrorMessage(''); setErrorKind(null); - if (!isValidId) { - setErrorKind('invalid-id'); - setErrorMessage('Invalid block id.'); + if (!isValidHash) { + setErrorKind('invalid-hash'); + setErrorMessage('Invalid block hash.'); return; } @@ -102,7 +102,7 @@ export default function BlockDetailPage({ parameters }) { (async () => { try { - const res = await fetch(API.BLOCK_DETAIL_BY_ID(blockId), { + const res = await fetch(API.BLOCK_DETAIL_BY_HASH(blockHash), { cache: 'no-cache', signal: controller.signal, }); @@ -127,7 +127,7 @@ export default function BlockDetailPage({ parameters }) { alive = false; controller.abort(); }; - }, [blockId, isValidId]); + }, [blockHash, isValidHash]); const header = block?.header ?? {}; // back-compat only 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.* const slot = block?.slot ?? header?.slot ?? null; const blockRoot = block?.block_root ?? header?.block_root ?? ''; - const blockHash = block?.hash ?? header?.hash ?? ''; - const parentId = block?.parent_id ?? null; + const currentBlockHash = block?.hash ?? header?.hash ?? ''; const parentHash = block?.parent_block_hash ?? header?.parent_block ?? ''; return h( @@ -152,7 +151,7 @@ export default function BlockDetailPage({ parameters }) { ), // Error states - errorKind === 'invalid-id' && h('p', { style: 'color:#ff8a8a' }, errorMessage), + errorKind === 'invalid-hash' && h('p', { style: 'color:#ff8a8a' }, errorMessage), errorKind === 'not-found' && h( 'div', @@ -202,12 +201,12 @@ export default function BlockDetailPage({ parameters }) { 'span', { class: 'pill mono', - title: blockHash, + title: currentBlockHash, 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) @@ -227,32 +226,32 @@ export default function BlockDetailPage({ parameters }) { 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', { style: 'display:flex; gap:8px; flex-wrap:wrap; align-items:flex-start;' }, - parentId != null + parentHash ? h( 'a', { class: 'pill mono linkish', - href: PAGE.BLOCK_DETAIL(parentId), - title: String(parentId), + href: PAGE.BLOCK_DETAIL(parentHash), + title: String(parentHash), style: 'max-width:100%; overflow-wrap:anywhere; word-break:break-word;', }, - String(parentId), + shortenHex(parentHash), ) : h( 'span', { class: 'pill mono', - title: parentHash, + title: '—', 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( 'tr', 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( 'th', { style: 'text-align:center; padding:8px 10px; white-space:nowrap;' }, @@ -311,8 +314,8 @@ export default function BlockDetailPage({ parameters }) { return h( 'tr', - { key: t?.id ?? `${count}/${total}` }, - // ID (left) + { key: t?.hash ?? `${count}/${total}` }, + // Hash (left) h( 'td', { style: 'text-align:left; padding:8px 10px; white-space:nowrap;' }, @@ -320,10 +323,10 @@ export default function BlockDetailPage({ parameters }) { 'a', { class: 'linkish mono', - href: PAGE.TRANSACTION_DETAIL(t?.id ?? ''), - title: String(t?.id ?? ''), + href: PAGE.TRANSACTION_DETAIL(t?.hash ?? ''), + title: String(t?.hash ?? ''), }, - String(t?.id ?? ''), + shortenHex(t?.hash ?? ''), ), ), // Outputs (center) diff --git a/static/pages/TransactionDetail.js b/static/pages/TransactionDetail.js index 35f9429..59677df 100644 --- a/static/pages/TransactionDetail.js +++ b/static/pages/TransactionDetail.js @@ -1,7 +1,8 @@ // static/pages/TransactionDetail.js import { h, Fragment } from 'preact'; 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 ————— const isNumber = (v) => typeof v === 'number' && !Number.isNaN(v); @@ -105,7 +106,7 @@ function normalizeTransaction(raw) { return { id: raw?.id ?? '', - blockId: raw?.block_id ?? null, + blockHash: raw?.block_hash ?? null, hash: renderBytes(raw?.hash), proof: renderBytes(raw?.proof), operations: ops, // keep objects, we’ll label in UI @@ -143,15 +144,15 @@ function Summary({ tx }) { { style: 'display:grid; gap:8px;' }, // Block link - tx.blockId != null && + tx.blockHash != null && h( 'div', null, h('b', null, 'Block: '), h( 'a', - { class: 'linkish mono', href: API.BLOCK_DETAIL_BY_ID(tx.blockId), title: String(tx.blockId) }, - String(tx.blockId), + { class: 'linkish mono', href: PAGE.BLOCK_DETAIL(tx.blockHash), title: String(tx.blockHash) }, + shortenHex(tx.blockHash), ), ), @@ -349,14 +350,13 @@ function Ledger({ ledger }) { // ————— page ————— export default function TransactionDetail({ parameters }) { - const idParam = parameters?.[0]; - const id = Number.parseInt(String(idParam), 10); - const isValidId = Number.isInteger(id) && id >= 0; + const transactionHash = parameters?.[0]; + const isValidHash = typeof transactionHash === 'string' && transactionHash.length > 0; const [tx, setTx] = useState(null); 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(() => { document.title = pageTitle; }, [pageTitle]); @@ -365,8 +365,8 @@ export default function TransactionDetail({ parameters }) { setTx(null); setErr(null); - if (!isValidId) { - setErr({ kind: 'invalid', msg: 'Invalid transaction id.' }); + if (!isValidHash) { + setErr({ kind: 'invalid', msg: 'Invalid transaction hash.' }); return; } @@ -375,7 +375,7 @@ export default function TransactionDetail({ parameters }) { (async () => { try { - const res = await fetch(API.TRANSACTION_DETAIL_BY_ID(id), { + const res = await fetch(API.TRANSACTION_DETAIL_BY_HASH(transactionHash), { cache: 'no-cache', signal: controller.signal, }); @@ -397,7 +397,7 @@ export default function TransactionDetail({ parameters }) { alive = false; controller.abort(); }; - }, [id, isValidId]); + }, [transactionHash, isValidHash]); return h( 'main',