fix parsing for latest blockchain and add a few features for devnet

This commit is contained in:
David Rusu 2026-02-05 12:41:42 +04:00
parent 02658ac293
commit 1d8c0fdec9
14 changed files with 317 additions and 199 deletions

View File

@ -15,6 +15,23 @@ if TYPE_CHECKING:
from core.app import NBE
async def list_blocks(
request: NBERequest,
page: int = Query(0, ge=0),
page_size: int = Query(10, ge=1, le=100, alias="page-size"),
) -> Response:
blocks, total_count = await request.app.state.block_repository.get_paginated(page, page_size)
total_pages = (total_count + page_size - 1) // page_size # ceiling division
return JSONResponse({
"blocks": [BlockRead.from_block(block).model_dump(mode="json") for block in blocks],
"page": page,
"page_size": page_size,
"total_count": total_count,
"total_pages": total_pages,
})
async def _get_blocks_stream_serialized(app: "NBE", block_from: Option[Block]) -> AsyncIterator[List[BlockRead]]:
_stream = app.state.block_repository.updates_stream(block_from)
async for blocks in _stream:

View File

@ -18,6 +18,7 @@ def create_v1_router() -> APIRouter:
router.add_api_route("/transactions/{transaction_hash:str}", transactions.get, methods=["GET"])
router.add_api_route("/blocks/stream", blocks.stream, methods=["GET"])
router.add_api_route("/blocks/list", blocks.list_blocks, methods=["GET"])
router.add_api_route("/blocks/{block_hash:str}", blocks.get, methods=["GET"])
return router

View File

@ -11,7 +11,14 @@ def dehexify(data: str) -> bytes:
return bytes.fromhex(data)
def validate_hex_bytes(data: str | bytes) -> bytes:
if isinstance(data, bytes):
return data
return bytes.fromhex(data)
HexBytes = Annotated[
bytes,
BeforeValidator(validate_hex_bytes),
PlainSerializer(hexify, return_type=str, when_used="json"),
]

View File

@ -77,6 +77,30 @@ class BlockRepository:
else:
return Empty()
async def get_paginated(self, page: int, page_size: int) -> tuple[List[Block], int]:
"""
Get blocks with pagination, ordered by slot descending (newest first).
Returns a tuple of (blocks, total_count).
"""
offset = page * page_size
with self.client.session() as session:
# Get total count
from sqlalchemy import func
count_statement = select(func.count()).select_from(Block)
total_count = session.exec(count_statement).one()
# Get paginated blocks
statement = (
select(Block)
.order_by(Block.slot.desc(), Block.id.desc())
.offset(offset)
.limit(page_size)
)
blocks = session.exec(statement).all()
return blocks, total_count
async def updates_stream(
self, block_from: Option[Block], *, timeout_seconds: int = 1
) -> AsyncIterator[List[Block]]:

View File

@ -1,2 +1 @@
from .proof_of_leadership import ProofOfLeadership
from .public import Public

View File

@ -3,7 +3,6 @@ from typing import Optional, Union
from core.models import NbeSchema
from core.types import HexBytes
from models.header.public import Public
class ProofOfLeadershipType(Enum):
@ -19,7 +18,6 @@ class Groth16ProofOfLeadership(NbeProofOfLeadership):
entropy_contribution: HexBytes
leader_key: HexBytes
proof: HexBytes
public: Optional[Public]
voucher_cm: HexBytes

View File

@ -1,10 +0,0 @@
from core.models import NbeSchema
from core.types import HexBytes
class Public(NbeSchema):
aged_root: HexBytes
epoch_nonce: HexBytes
latest_root: HexBytes
slot: int
total_stake: int

View File

@ -4,14 +4,14 @@ from typing import List, Self
from pydantic import Field
from core.models import NbeSerializer
from node.api.serializers.fields import BytesFromIntArray
from node.api.serializers.fields import BytesFromHex
from node.api.serializers.note import NoteSerializer
from utils.protocols import FromRandom
from utils.random import random_bytes
class LedgerTransactionSerializer(NbeSerializer, FromRandom):
inputs: List[BytesFromIntArray] = Field(description="Fr integer.")
inputs: List[BytesFromHex] = Field(description="Fr integer.")
outputs: List[NoteSerializer]
@classmethod
@ -21,7 +21,7 @@ class LedgerTransactionSerializer(NbeSerializer, FromRandom):
return cls.model_validate(
{
"inputs": [list(random_bytes(2048)) for _ in range(n_inputs)],
"inputs": [random_bytes(32).hex() for _ in range(n_inputs)],
"outputs": [NoteSerializer.from_random() for _ in range(n_outputs)],
}
)

View File

@ -10,7 +10,6 @@ from models.header.proof_of_leadership import (
ProofOfLeadership,
)
from node.api.serializers.fields import BytesFromHex, BytesFromIntArray
from node.api.serializers.public import PublicSerializer
from utils.protocols import EnforceSubclassFromRandom
from utils.random import random_bytes
@ -27,17 +26,14 @@ class Groth16LeaderProofSerializer(ProofOfLeadershipSerializer, NbeSerializer):
proof: BytesFromIntArray = Field(
description="Bytes in Integer Array format.",
)
public: Optional[PublicSerializer] = Field(description="Only received if Node is running in dev mode.")
voucher_cm: BytesFromHex = Field(description="Hash.")
def into_proof_of_leadership(self) -> ProofOfLeadership:
public = self.public.into_public() if self.public else None
return Groth16ProofOfLeadership.model_validate(
{
"entropy_contribution": self.entropy_contribution,
"leader_key": self.leader_key,
"proof": self.proof,
"public": public,
"voucher_cm": self.voucher_cm,
}
)
@ -49,7 +45,6 @@ class Groth16LeaderProofSerializer(ProofOfLeadershipSerializer, NbeSerializer):
"entropy_contribution": random_bytes(32).hex(),
"leader_key": random_bytes(32).hex(),
"proof": list(random_bytes(128)),
"public": PublicSerializer.from_random(slot),
"voucher_cm": random_bytes(32).hex(),
}
)

View File

@ -1,42 +0,0 @@
from random import randint
from typing import Self
from pydantic import Field
from rusty_results import Option
from core.models import NbeSerializer
from models.header.public import Public
from node.api.serializers.fields import BytesFromHex
from utils.protocols import FromRandom
from utils.random import random_bytes
class PublicSerializer(NbeSerializer, FromRandom):
aged_root: BytesFromHex = Field(description="Fr integer in hex format.")
epoch_nonce: BytesFromHex = Field(description="Fr integer in hex format.")
latest_root: BytesFromHex = Field(description="Fr integer in hex format.")
slot: int = Field(description="Integer in u64 format.")
total_stake: int = Field(description="Integer in u64 format.")
def into_public(self) -> Public:
return Public.model_validate(
{
"aged_root": self.aged_root,
"epoch_nonce": self.epoch_nonce,
"latest_root": self.latest_root,
"slot": self.slot,
"total_stake": self.total_stake,
}
)
@classmethod
def from_random(cls, slot: Option[int]) -> Self:
cls.model_validate(
{
"aged_root": random_bytes(32).hex(),
"epoch_nonce": random_bytes(32).hex(),
"latest_root": random_bytes(32).hex(),
"slot": slot.unwrap_or(randint(0, 10_000)),
"total_stake": randint(0, 10_000),
}
)

View File

@ -1,11 +1,10 @@
from typing import List, Self
from pydantic import Field
from rusty_results import Option
from core.models import NbeSerializer
from models.transactions.transaction import Transaction
from node.api.serializers.fields import BytesFromHex
from node.api.serializers.fields import BytesFromIntArray
from node.api.serializers.proof import (
OperationProofSerializer,
OperationProofSerializerField,
@ -15,14 +14,31 @@ from utils.protocols import FromRandom
from utils.random import random_bytes
class Groth16ProofSerializer(NbeSerializer, FromRandom):
pi_a: BytesFromIntArray
pi_b: BytesFromIntArray
pi_c: BytesFromIntArray
def to_bytes(self) -> bytes:
return self.pi_a + self.pi_b + self.pi_c
@classmethod
def from_random(cls) -> Self:
return cls.model_validate(
{
"pi_a": list(random_bytes(32)),
"pi_b": list(random_bytes(64)),
"pi_c": list(random_bytes(32)),
}
)
class SignedTransactionSerializer(NbeSerializer, FromRandom):
transaction: TransactionSerializer = Field(alias="mantle_tx", description="Transaction.")
operations_proofs: List[OperationProofSerializerField] = Field(
alias="ops_proofs", description="List of OperationProof. Order should match `Self::transaction::operations`."
)
ledger_transaction_proof: BytesFromHex = Field(
alias="ledger_tx_proof", description="Hash.", min_length=128, max_length=128
)
ledger_transaction_proof: Groth16ProofSerializer = Field(alias="ledger_tx_proof", description="Groth16 proof.")
def into_transaction(self) -> Transaction:
operations_contents = self.transaction.operations_contents
@ -48,7 +64,7 @@ class SignedTransactionSerializer(NbeSerializer, FromRandom):
"operations": operations,
"inputs": ledger_transaction.inputs,
"outputs": outputs,
"proof": self.ledger_transaction_proof,
"proof": self.ledger_transaction_proof.to_bytes(),
"execution_gas_price": self.transaction.execution_gas_price,
"storage_gas_price": self.transaction.storage_gas_price,
}
@ -60,5 +76,5 @@ class SignedTransactionSerializer(NbeSerializer, FromRandom):
n = len(transaction.operations_contents)
operations_proofs = [OperationProofSerializer.from_random() for _ in range(n)]
return cls.model_validate(
{"mantle_tx": transaction, "ops_proofs": operations_proofs, "ledger_tx_proof": random_bytes(128).hex()}
{"mantle_tx": transaction, "ops_proofs": operations_proofs, "ledger_tx_proof": Groth16ProofSerializer.from_random()}
)

View File

@ -1,158 +1,239 @@
// static/pages/BlocksTable.js
// static/components/BlocksTable.js
import { h } from 'preact';
import { useEffect, useRef } from 'preact/hooks';
import { useEffect, useState, useCallback, useRef } from 'preact/hooks';
import { PAGE, API } from '../lib/api.js';
import { TABLE_SIZE } from '../lib/constants.js';
import { streamNdjson, ensureFixedRowCount, shortenHex } from '../lib/utils.js';
import { shortenHex, streamNdjson } from '../lib/utils.js';
const normalize = (raw) => {
const header = raw.header ?? null;
const txLen = Array.isArray(raw.transactions)
? raw.transactions.length
: Array.isArray(raw.txs)
? raw.txs.length
: 0;
return {
id: Number(raw.id ?? 0),
slot: Number(raw.slot ?? header?.slot ?? 0),
hash: raw.hash ?? header?.hash ?? '',
parent: raw.parent_block_hash ?? header?.parent_block ?? raw.parent_block ?? '',
root: raw.block_root ?? header?.block_root ?? '',
transactionCount: txLen,
};
};
export default function BlocksTable() {
const bodyRef = useRef(null);
const countRef = useRef(null);
const [blocks, setBlocks] = useState([]);
const [page, setPage] = useState(0);
const [totalPages, setTotalPages] = useState(0);
const [totalCount, setTotalCount] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [live, setLive] = useState(true); // Start in live mode
const abortRef = useRef(null);
const seenKeysRef = useRef(new Set());
useEffect(() => {
const body = bodyRef.current;
const counter = countRef.current;
// Fetch paginated blocks
const fetchBlocks = useCallback(async (pageNum) => {
// Stop any live stream
abortRef.current?.abort();
seenKeysRef.current.clear();
// 5 columns: Hash | Slot | Parent | Block Root | Transactions
ensureFixedRowCount(body, 5, TABLE_SIZE);
setLoading(true);
setError(null);
try {
const res = await fetch(API.BLOCKS_LIST(pageNum, TABLE_SIZE));
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
setBlocks(data.blocks.map(normalize));
setTotalPages(data.total_pages);
setTotalCount(data.total_count);
setPage(data.page);
} catch (e) {
setError(e.message);
} finally {
setLoading(false);
}
}, []);
// Start live streaming
const startLiveStream = useCallback(() => {
abortRef.current?.abort();
abortRef.current = new AbortController();
seenKeysRef.current.clear();
setBlocks([]);
setLoading(true);
setError(null);
const pruneAndPad = () => {
// remove any placeholder rows that snuck in
for (let i = body.rows.length - 1; i >= 0; i--) {
if (body.rows[i].classList.contains('ph')) body.deleteRow(i);
}
// keep at most TABLE_SIZE non-placeholder rows
while ([...body.rows].filter((r) => !r.classList.contains('ph')).length > TABLE_SIZE) {
const last = body.rows[body.rows.length - 1];
const key = last?.dataset?.key;
if (key) seenKeysRef.current.delete(key);
body.deleteRow(-1);
}
// 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 = (blockHash) => {
history.pushState({}, '', PAGE.BLOCK_DETAIL(blockHash));
window.dispatchEvent(new PopStateEvent('popstate'));
};
const appendRow = (b, key) => {
const tr = document.createElement('tr');
tr.dataset.key = key;
// Hash (clickable, replaces ID)
const tdId = document.createElement('td');
const linkId = document.createElement('a');
linkId.className = 'linkish mono';
linkId.href = PAGE.BLOCK_DETAIL(b.hash);
linkId.textContent = shortenHex(b.hash);
linkId.title = b.hash;
linkId.addEventListener('click', (e) => {
e.preventDefault();
navigateToBlockDetail(b.hash);
});
tdId.appendChild(linkId);
// Slot
const tdSlot = document.createElement('td');
const spSlot = document.createElement('span');
spSlot.className = 'mono';
spSlot.textContent = String(b.slot);
tdSlot.appendChild(spSlot);
// Parent (block.parent_block_hash)
const tdParent = document.createElement('td');
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');
const spRoot = document.createElement('span');
spRoot.className = 'mono';
spRoot.title = b.root;
spRoot.textContent = shortenHex(b.root);
tdRoot.appendChild(spRoot);
// Transactions (array length)
const tdCount = document.createElement('td');
const spCount = document.createElement('span');
spCount.className = 'mono';
spCount.textContent = String(b.transactionCount);
tdCount.appendChild(spCount);
tr.append(tdId, tdSlot, tdParent, tdRoot, tdCount);
body.insertBefore(tr, body.firstChild);
pruneAndPad();
};
const normalize = (raw) => {
// New backend:
// { id, hash, slot, block_root, parent_block_hash, transactions: [...] }
// Back-compat (header.* / raw.parent_block) just in case.
const header = raw.header ?? null;
const txLen = Array.isArray(raw.transactions)
? raw.transactions.length
: Array.isArray(raw.txs)
? raw.txs.length
: 0;
return {
id: Number(raw.id ?? 0),
slot: Number(raw.slot ?? header?.slot ?? 0),
hash: raw.hash ?? header?.hash ?? '',
parent: raw.parent_block_hash ?? header?.parent_block ?? raw.parent_block ?? '',
root: raw.block_root ?? header?.block_root ?? '',
transactionCount: txLen,
};
};
let liveBlocks = [];
streamNdjson(
`${API.BLOCKS_STREAM}?prefetch-limit=${encodeURIComponent(TABLE_SIZE)}`,
(raw) => {
const b = normalize(raw);
const key = `${b.id}:${b.slot}`;
if (seenKeysRef.current.has(key)) {
pruneAndPad();
return;
}
if (seenKeysRef.current.has(key)) return;
seenKeysRef.current.add(key);
appendRow(b, key);
// Add to front, keep max TABLE_SIZE
liveBlocks = [b, ...liveBlocks].slice(0, TABLE_SIZE);
setBlocks([...liveBlocks]);
setTotalCount(liveBlocks.length);
setLoading(false);
},
{
signal: abortRef.current.signal,
onError: (e) => {
console.error('Blocks stream error:', e);
if (e?.name !== 'AbortError') {
console.error('Blocks stream error:', e);
setError(e?.message || 'Stream error');
}
},
},
);
return () => abortRef.current?.abort();
}, []);
// Handle live mode changes
useEffect(() => {
if (live) {
startLiveStream();
}
return () => abortRef.current?.abort();
}, [live, startLiveStream]);
// Go to a page (turns off live mode)
const goToPage = (newPage) => {
if (newPage >= 0) {
setLive(false);
fetchBlocks(newPage);
}
};
// Toggle live mode
const toggleLive = () => {
if (!live) {
setLive(true);
setPage(0);
}
};
const navigateToBlockDetail = (blockHash) => {
history.pushState({}, '', PAGE.BLOCK_DETAIL(blockHash));
window.dispatchEvent(new PopStateEvent('popstate'));
};
const renderRow = (b, idx) => {
return h(
'tr',
{ key: b.id || idx },
// Hash
h(
'td',
null,
h(
'a',
{
class: 'linkish mono',
href: PAGE.BLOCK_DETAIL(b.hash),
title: b.hash,
onClick: (e) => {
e.preventDefault();
navigateToBlockDetail(b.hash);
},
},
shortenHex(b.hash),
),
),
// Slot
h('td', null, h('span', { class: 'mono' }, String(b.slot))),
// Parent
h(
'td',
null,
h(
'a',
{
class: 'linkish mono',
href: PAGE.BLOCK_DETAIL(b.parent),
title: b.parent,
onClick: (e) => {
e.preventDefault();
navigateToBlockDetail(b.parent);
},
},
shortenHex(b.parent),
),
),
// Block Root
h('td', null, h('span', { class: 'mono', title: b.root }, shortenHex(b.root))),
// Transactions
h('td', null, h('span', { class: 'mono' }, String(b.transactionCount))),
);
};
const renderPlaceholderRow = (idx) => {
return h(
'tr',
{ key: `ph-${idx}`, class: 'ph' },
h('td', null, '\u00A0'),
h('td', null, '\u00A0'),
h('td', null, '\u00A0'),
h('td', null, '\u00A0'),
h('td', null, '\u00A0'),
);
};
const rows = [];
for (let i = 0; i < TABLE_SIZE; i++) {
if (i < blocks.length) {
rows.push(renderRow(blocks[i], i));
} else {
rows.push(renderPlaceholderRow(i));
}
}
// Live button styles
const liveButtonStyle = live
? `
cursor: pointer;
background: #ff4444;
color: white;
border: none;
animation: live-pulse 1.5s ease-in-out infinite;
`
: `
cursor: pointer;
background: var(--bg-secondary, #333);
color: var(--muted, #888);
border: 1px solid var(--border, #444);
`;
return h(
'div',
{ class: 'card' },
// Inject keyframes for the pulse animation
h('style', null, `
@keyframes live-pulse {
0%, 100% { box-shadow: 0 0 4px #ff4444, 0 0 8px #ff4444; }
50% { box-shadow: 0 0 8px #ff4444, 0 0 16px #ff4444, 0 0 24px #ff6666; }
}
`),
h(
'div',
{ class: 'card-header' },
h('div', null, h('strong', null, 'Blocks '), h('span', { class: 'pill', ref: countRef }, '0')),
h('div', { style: 'color:var(--muted); fontSize:12px;' }),
{ class: 'card-header', style: 'display:flex; justify-content:space-between; align-items:center;' },
h('div', null, h('strong', null, 'Blocks '), h('span', { class: 'pill' }, String(totalCount))),
h(
'button',
{
class: 'pill',
style: liveButtonStyle,
onClick: toggleLive,
title: live ? 'Live updates enabled' : 'Click to enable live updates',
},
live ? 'LIVE \u2022' : 'LIVE',
),
),
h(
'div',
@ -182,8 +263,43 @@ export default function BlocksTable() {
h('th', null, 'Transactions'),
),
),
h('tbody', { ref: bodyRef }),
h('tbody', null, ...rows),
),
),
// Pagination controls
h(
'div',
{
class: 'card-footer',
style: 'display:flex; justify-content:space-between; align-items:center; padding:8px 14px; border-top:1px solid var(--border);',
},
h(
'button',
{
class: 'pill',
disabled: page === 0 || loading,
onClick: () => goToPage(page - 1),
style: 'cursor:pointer;',
},
'Previous',
),
h(
'span',
{ style: 'color:var(--muted); font-size:13px;' },
live ? 'Streaming live blocks...' : totalPages > 0 ? `Page ${page + 1} of ${totalPages}` : 'No blocks',
),
h(
'button',
{
class: 'pill',
disabled: (!live && page >= totalPages - 1) || loading,
onClick: () => live ? goToPage(0) : goToPage(page + 1),
style: 'cursor:pointer;',
},
'Next',
),
),
// Error display
error && h('div', { style: 'padding:8px 14px; color:#ff8a8a;' }, `Error: ${error}`),
);
}

View File

@ -10,6 +10,8 @@ const TRANSACTIONS_STREAM = joinUrl(API_PREFIX, 'transactions/stream');
const BLOCK_DETAIL_BY_HASH = (hash) => joinUrl(API_PREFIX, 'blocks', encodeHash(hash));
const BLOCKS_STREAM = joinUrl(API_PREFIX, 'blocks/stream');
const BLOCKS_LIST = (page, pageSize) =>
`${joinUrl(API_PREFIX, 'blocks/list')}?page=${encodeURIComponent(page)}&page-size=${encodeURIComponent(pageSize)}`;
export const API = {
HEALTH_ENDPOINT,
@ -17,6 +19,7 @@ export const API = {
TRANSACTIONS_STREAM,
BLOCK_DETAIL_BY_HASH,
BLOCKS_STREAM,
BLOCKS_LIST,
};
const BLOCK_DETAIL = (hash) => joinUrl('/blocks', encodeHash(hash));

View File

@ -218,7 +218,7 @@ function InputsTable({ inputs }) {
h('col', { style: 'width:80px' }), // #
h('col', null), // Value
),
h('thead', null, h('tr', null, h('th', { style: 'text-align:center;' }, '#'), h('th', null, 'Value'))),
h('thead', null, h('tr', null, h('th', { style: 'text-align:center;' }, '#'), h('th', null, 'Note ID'))),
h(
'tbody',
null,
@ -299,7 +299,6 @@ function OutputsTable({ outputs }) {
function Ledger({ ledger }) {
const inputs = Array.isArray(ledger?.inputs) ? ledger.inputs : [];
const outputs = Array.isArray(ledger?.outputs) ? ledger.outputs : [];
const totalInputValue = inputs.reduce((s, v) => s + toNumber(v), 0);
const totalOutputValue = toNumber(ledger?.totalOutputValue);
return h(
@ -318,11 +317,6 @@ function Ledger({ ledger }) {
{ style: 'display:flex; alignItems:center; gap:8px;' },
h('b', null, 'Inputs'),
h('span', { class: 'pill' }, String(inputs.length)),
h(
'span',
{ class: 'amount', style: 'margin-left:auto;' },
`Total: ${toLocaleNum(totalInputValue)}`,
),
),
h(InputsTable, { inputs }),
),