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

View File

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

View File

@ -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,

View File

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

View File

@ -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"),

View File

@ -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:

View File

@ -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:

View File

@ -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'),

View File

@ -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)'),

View File

@ -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,

View File

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

View File

@ -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, well 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',