feat: block explorer routes, addressed by hash
All checks were successful
deploy / build (push) Successful in 6m33s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 16s

`#/:chain/:height` opens a block and then rewrites the address to
`#/:chain/:hash`. The redirect is the point, not tidiness: `block` is keyed on
(chain, height) and a reorg overwrites, so a height names a *position* and a
link to one comes to mean a different block the moment the chain forks there. A
hash names one block for good — including after it has lost, which is the only
way an orphan is linkable at all.

`GET /v1/chains/{chain}/blocks/{ref}` takes either spelling; a decimal number
and 32 bytes of hex cannot be confused, so a caller holding a number is not made
to guess which one this API wanted. The same reasoning puts block refs in the
second path segment beside the window: no window name is all digits or 0x plus
64 hex characters.

The answer is assembled from two sources because neither is sufficient, and
they fail at different times:

- The node holds the block and forgets it. Non-canonical bodies go once finality
  passes them (blocks-pruning defaults to archive-canonical, and Planck finalises
  ~100 blocks back), and difficulty is a state read behind a 256-block default.
- The observer holds what the node never had — when the block was seen, whether
  that sighting was at the tip — and what it has since forgotten: the difficulty
  read while the state behind it still existed.

So resolution degrades in a stated order rather than failing. Node first; a
header it cannot serve falls back to `block_displacement`, which is what makes
an orphan describable at all; a node that is away falls back to the recorded
row. `from_node` tells the reader which they are looking at, and every value
neither source has is spelled out in words — "the body is pruned", "this
observer never saw this block" — because a blank on a block page reads as zero.

Verified against the live chain: a tip block carries difficulty, one extrinsic,
a 1.9 s gap and 473 ms of propagation; height 1000000 keeps its body and loses
difficulty to state pruning; genesis has no author digest and does not panic; a
seeded displacement serves as an orphan with the winner linked, and the winner
lists it in `also_seen`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
This commit is contained in:
2026-09-08 16:30:41 +03:00
parent 0ce58094e4
commit 53dac3717b
17 changed files with 1270 additions and 22 deletions

View File

@@ -0,0 +1,59 @@
{
"db_name": "PostgreSQL",
"query": "\n select height, hash, miner, authored_at, observed_at,\n difficulty::text as difficulty, at_tip\n from block\n where chain = $1 and height = $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "height",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "hash",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "miner",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "authored_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "observed_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "difficulty",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "at_tip",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": [
false,
false,
false,
true,
false,
null,
false
]
},
"hash": "48ec2e94c55c11aadc8baefd787ccb9837537c0b2b8348742bb08d3d211cec04"
}

View File

@@ -0,0 +1,59 @@
{
"db_name": "PostgreSQL",
"query": "\n select height, hash, miner, authored_at, observed_at,\n difficulty::text as difficulty, at_tip\n from block_displacement\n where chain = $1 and hash = $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "height",
"type_info": "Int8"
},
{
"ordinal": 1,
"name": "hash",
"type_info": "Text"
},
{
"ordinal": 2,
"name": "miner",
"type_info": "Text"
},
{
"ordinal": 3,
"name": "authored_at",
"type_info": "Timestamptz"
},
{
"ordinal": 4,
"name": "observed_at",
"type_info": "Timestamptz"
},
{
"ordinal": 5,
"name": "difficulty",
"type_info": "Text"
},
{
"ordinal": 6,
"name": "at_tip",
"type_info": "Bool"
}
],
"parameters": {
"Left": [
"Text",
"Text"
]
},
"nullable": [
false,
false,
false,
true,
false,
null,
false
]
},
"hash": "6faa701a33e2851503d3ab891e13ba57b2ee5c9f6d24ae5f914eef45136ae24b"
}

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n select hash from block_displacement\n where chain = $1 and height = $2\n order by displaced_at desc\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "hash",
"type_info": "Text"
}
],
"parameters": {
"Left": [
"Text",
"Int8"
]
},
"nullable": [
false
]
},
"hash": "e45a56833ce1558f0f76061b7fc8c5866f11f191ff1bb44dfbd03cb78d36e963"
}

View File

@@ -215,5 +215,14 @@ cargo run -p blackbeard-cli -- standings --chain planck
curl -s localhost:25864/v1/chains/planck/summary | python3 -m json.tool
```
The block route degrades rather than failing, so it is worth checking both ends
of that: a recent height should carry `difficulty`, `extrinsics` and
`seconds_since_parent`, while one a few days old keeps the body and loses
difficulty to state pruning unless this observer recorded it at the time.
```sh
curl -s localhost:25864/v1/chains/planck/blocks/1070000 | python3 -m json.tool
```
A working deployment shows a non-null `telemetry_nodes`, `distinct_miners` above
one, and named rows in the standings.

View File

@@ -16,8 +16,8 @@ use axum::response::{IntoResponse, Response};
use axum::routing::get;
use axum::{Json, Router};
use blackbeard_entities::{
ApiError, BigUintDec, ChainInfo, ChainSeries, ChainSummary, LeaderboardRow, MinerDetail,
MinerId, MinerSeriesPoint, RecentBlock, Window,
ApiError, BigUintDec, BlockDetail, ChainInfo, ChainSeries, ChainSummary, LeaderboardRow,
MinerDetail, MinerId, MinerSeriesPoint, RecentBlock, Window,
};
use serde::{Deserialize, Serialize};
use tower_http::compression::CompressionLayer;
@@ -48,6 +48,7 @@ pub fn router(state: AppState, allowed_origins: &[String]) -> Router {
.route("/v1/chains/{chain}/leaderboard", get(leaderboard))
.route("/v1/chains/{chain}/blocks", get(blocks))
.route("/v1/chains/{chain}/series", get(series))
.route("/v1/chains/{chain}/blocks/{block}", get(block))
.route("/v1/chains/{chain}/miners/{miner}", get(miner))
.route("/v1/ws", get(crate::ws::handler))
// Leaderboards are repetitive JSON and compress to a fraction of their
@@ -188,6 +189,250 @@ async fn blocks(
Ok(Json(runtime.ticker()))
}
/// `GET /v1/chains/{chain}/blocks/{block}`
///
/// One block, addressed by height or by hash.
///
/// ## Why both, and why the hash is the real address
///
/// `block` is keyed on `(chain, height)` and a reorg overwrites, so a height
/// names *whatever currently holds it* — a link to one is a link to a position,
/// not to a block, and it silently comes to mean something else the moment the
/// chain forks there. A hash names one block forever. The frontend resolves a
/// height and then redirects to the hash for exactly that reason.
///
/// ## Why the answer is assembled from two places
///
/// The node holds the block; the observer holds what the node forgets. Both are
/// needed and neither is sufficient:
///
/// - Difficulty is a state read behind a 256-block default window, so beyond a
/// few minutes of history only the recorded row can answer.
/// - `observed_at` and `at_tip` are observations. The chain never had them.
/// - A displaced block's body is discarded once finality passes it
/// (`blocks-pruning` defaults to `archive-canonical`), so an orphan is
/// describable only from `block_displacement`, written when it was displaced.
///
/// Which is why this degrades in a specific order rather than failing: ask the
/// node, fall back to what was recorded, and say in `from_node` which of the two
/// the reader is looking at.
async fn block(
State(state): State<AppState>,
Path((chain, reference)): Path<(String, String)>,
) -> Result<Json<BlockDetail>, Failure> {
let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?;
let id = runtime.id();
// A height and a hash are not ambiguous — one is decimal, the other 32 bytes
// of hex — so one route serves both rather than making a caller who has a
// number guess which spelling this API wanted.
let detail = match parse_block_ref(&reference)? {
BlockRef::Height(height) => from_height(&state, &runtime, height).await,
BlockRef::Hash(hash) => from_hash(&state, &runtime, &hash).await,
};
detail.map(Json).ok_or_else(|| {
Failure(
StatusCode::NOT_FOUND,
ApiError::new(
"unknown_block",
format!("neither the node nor this observer has `{reference}` on {id}"),
),
)
})
}
/// How a block was asked for.
enum BlockRef {
/// A block number. Names a position, and therefore whatever holds it now.
Height(u64),
/// A block hash. Names one block, for good.
Hash(String),
}
fn parse_block_ref(raw: &str) -> Result<BlockRef, Failure> {
if let Ok(height) = raw.parse::<u64>() {
return Ok(BlockRef::Height(height));
}
let hex = raw.strip_prefix("0x").unwrap_or(raw);
if hex.len() == 64 && hex.chars().all(|c| c.is_ascii_hexdigit()) {
return Ok(BlockRef::Hash(format!("0x{}", hex.to_ascii_lowercase())));
}
Err(Failure(
StatusCode::BAD_REQUEST,
ApiError::new(
"malformed_block",
format!("`{raw}` is neither a block number nor a 32-byte hash"),
),
))
}
/// Resolve a height to the block that currently holds it.
async fn from_height(
state: &AppState,
runtime: &crate::state::ChainRuntime,
height: u64,
) -> Option<BlockDetail> {
match runtime.rpc.block_hash(height).await {
Ok(Some(hash)) => from_hash(state, runtime, &hash).await,
// The node is unreachable or has no such height. The observer may still
// have watched it — the whole point of keeping a database — so a node
// outage costs the reader the body and nothing else.
_ => {
let recorded = state
.store
.recorded_block(&runtime.id(), height)
.await
.ok()??;
Some(from_record(state, runtime, recorded, true, None).await)
}
}
}
/// Describe one block by hash, preferring the node and falling back to what was
/// recorded when it cannot answer.
async fn from_hash(
state: &AppState,
runtime: &crate::state::ChainRuntime,
hash: &str,
) -> Option<BlockDetail> {
let id = runtime.id();
let Ok(Some(header)) = runtime.rpc.header(Some(hash)).await else {
// No header: either the node is away, or this is a fork it has pruned.
// A block we recorded being displaced is still fully describable from
// the row written at that moment, which is the reason it is written.
let displaced = state.store.displaced_block(&id, hash).await.ok()??;
let canonical_hash = runtime
.rpc
.block_hash(displaced.height)
.await
.ok()
.flatten();
return Some(from_record(state, runtime, displaced, false, canonical_hash).await);
};
let height = header.height()?;
// Canonical means "this hash is the one the chain currently has at this
// height". Asking is one call and it is the field that makes an orphan page
// legible rather than a confusing duplicate of a block that looks current.
let canonical_hash = runtime.rpc.block_hash(height).await.ok().flatten();
let canonical = canonical_hash.as_deref() == Some(hash);
let body = runtime.rpc.body(hash).await.ok().flatten();
let miner = blackbeard_core::digest::author_preimage(&header.digest.logs);
let authored_at = body.and_then(|b| b.timestamp_ms).and_then(millis);
// The parent's timestamp, for how long this block took to find. Asked of the
// node by parent hash rather than read at height-1, so it is this block's
// own parent even on a fork.
let seconds_since_parent = match (authored_at, runtime.rpc.body(&header.parent_hash).await) {
(Some(at), Ok(Some(parent))) => parent
.timestamp_ms
.and_then(millis)
.map(|p| (at - p).as_seconds_f64())
// A parent stamped after its child is the author's clock, not a
// negative block time. Reporting it would put a minus sign on the
// page and invite someone to explain it.
.filter(|s| *s >= 0.0),
_ => None,
};
// Resolved once: `attribute` takes the write lock and walks the vote window,
// and the three fields below are three views of one answer.
let named = miner.as_ref().map(|m| runtime.attribute(m));
let recorded = state.store.recorded_block(&id, height).await.ok().flatten();
// Only if it is the *same* block. A recorded row for a height this hash has
// lost describes the winner, and lifting its observation times onto this
// page would be an outright fabrication.
let ours = recorded.filter(|r| r.hash == hash);
Some(BlockDetail {
chain: id.clone(),
height,
hash: hash.to_owned(),
parent_hash: Some(header.parent_hash.clone()),
display: named.as_ref().map(|a| a.display.clone()),
attribution: named.as_ref().map(|a| a.source),
confidence: named.as_ref().map(|a| a.confidence),
// The recorded difficulty first: it was read while the state behind it
// still existed, and the node cannot go back for it.
difficulty: ours
.as_ref()
.and_then(|r| r.difficulty.clone())
.map(BigUintDec),
authored_at: authored_at.or_else(|| ours.as_ref().and_then(|r| r.authored_at)),
observed_at: ours.as_ref().map(|r| r.observed_at),
at_tip: ours.as_ref().map(|r| r.at_tip),
miner,
seconds_since_parent,
extrinsics: body.map(|b| b.extrinsics),
canonical,
canonical_hash,
also_seen: other_hashes(state, &id, height, hash).await,
from_node: true,
})
}
/// Describe a block from what the observer recorded, the node having failed to
/// answer for it.
async fn from_record(
state: &AppState,
runtime: &crate::state::ChainRuntime,
recorded: blackbeard_data::store::RecordedBlock,
canonical: bool,
canonical_hash: Option<String>,
) -> BlockDetail {
let id = runtime.id();
let attribution = runtime.attribute(&recorded.miner);
BlockDetail {
chain: id.clone(),
height: recorded.height,
hash: recorded.hash.clone(),
// All of these live in the header or the body, and the node is the only
// one who has either. None of them is guessable from a recorded row.
parent_hash: None,
seconds_since_parent: None,
extrinsics: None,
display: Some(attribution.display),
attribution: Some(attribution.source),
confidence: Some(attribution.confidence),
difficulty: recorded.difficulty.clone().map(BigUintDec),
authored_at: recorded.authored_at,
observed_at: Some(recorded.observed_at),
at_tip: Some(recorded.at_tip),
canonical: canonical_hash
.as_deref()
.map_or(canonical, |h| h == recorded.hash),
canonical_hash,
also_seen: other_hashes(state, &id, recorded.height, &recorded.hash).await,
miner: Some(recorded.miner),
from_node: false,
}
}
/// Every other hash this observer has seen hold `height`.
async fn other_hashes(
state: &AppState,
chain: &blackbeard_entities::ChainId,
height: u64,
self_hash: &str,
) -> Vec<String> {
state
.store
.hashes_seen_at(chain, height)
.await
.unwrap_or_default()
.into_iter()
.filter(|h| h != self_hash)
.collect()
}
fn millis(ms: u64) -> Option<chrono::DateTime<chrono::Utc>> {
chrono::DateTime::from_timestamp_millis(i64::try_from(ms).ok()?)
}
/// Points in a chain's history series.
///
/// Every selectable window divides by this exactly (600, 3,600, 14,400 and
@@ -334,6 +579,40 @@ fn database_unavailable(e: blackbeard_data::DataError) -> Failure {
mod tests {
use super::*;
#[test]
fn a_block_is_addressable_by_number_or_by_hash() {
let bare = "134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59";
assert!(matches!(
parse_block_ref("1069799"),
Ok(BlockRef::Height(1_069_799))
));
assert!(matches!(parse_block_ref("0"), Ok(BlockRef::Height(0))));
// Case-insensitive digits, canonical lowercase out, with or without the
// prefix — the same contract `parse_miner` offers, so a hash copied
// from a tool that upper-cases hex resolves to one page rather than
// several. The `0x` marker itself is lowercase in both, deliberately:
// two spellings of the prefix would be two URLs for one block.
for spelling in [
bare.to_owned(),
format!("0x{bare}"),
format!("0x{}", bare.to_uppercase()),
] {
match parse_block_ref(&spelling) {
Ok(BlockRef::Hash(h)) => assert_eq!(h, format!("0x{bare}")),
_ => panic!("{spelling} should parse as a hash"),
}
}
}
#[test]
fn anything_that_is_neither_a_number_nor_a_hash_is_a_bad_request() {
// A 404 here would read as "no such block", which is a different claim
// and sends the reader looking for a chain problem.
for bad in ["", "0x", "0xdeadbeef", "tip", "-1", "1.5", &"f".repeat(63)] {
assert!(parse_block_ref(bad).is_err(), "{bad} should be rejected");
}
}
#[test]
fn a_preimage_is_accepted_with_or_without_the_prefix() {
let bare = "134e73f06fa9bdb1dbfa909e149c563f5860ceb71a0e7307918f7033970edf59";

View File

@@ -26,8 +26,8 @@ use blackbeard_data::rpc::RpcClient;
use blackbeard_data::store::Store;
use blackbeard_data::telemetry::TelemetryFeed;
use blackbeard_entities::{
ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, LeaderboardRow, RecentBlock,
ServerMessage, Tracking, Window,
ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, LeaderboardRow, MinerId,
RecentBlock, ServerMessage, Tracking, Window,
};
use chrono::{DateTime, Utc};
use primitive_types::U512;
@@ -353,6 +353,20 @@ impl ChainRuntime {
self.recompute_leaderboard(window).0
}
/// What to call one miner, resolved the same way the leaderboard does.
///
/// Takes the write lock because the attributor caches its vote tallies, and
/// the caller gets one name rather than a whole board it would throw away.
/// CPU-only work on in-memory collections, like everything else under this
/// lock, and never held across an await.
pub fn attribute(&self, miner: &MinerId) -> blackbeard_core::attribution::Attribution {
let mut inner = self.write();
let telemetry = &self.telemetry;
inner
.attributor
.attribute(miner, |key| telemetry.name_of(key))
}
/// The live ticker, oldest first.
pub fn ticker(&self) -> Vec<RecentBlock> {
self.read().ticker.iter().cloned().collect()

View File

@@ -57,6 +57,15 @@ impl Header {
}
}
/// A block body, reduced to what the observer reads from it.
#[derive(Debug, Clone, Copy)]
pub struct BlockBody {
/// Extrinsics in the block, inherents included.
pub extrinsics: u32,
/// The author's timestamp from the `Timestamp::set` inherent.
pub timestamp_ms: Option<u64>,
}
/// What `system_health` reports.
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
@@ -170,14 +179,34 @@ impl RpcClient {
/// a shape we do not recognise, which costs this block its timing data and
/// nothing else.
pub async fn block_timestamp_ms(&self, hash: &str) -> Result<Option<u64>, DataError> {
Ok(self.body(hash).await?.and_then(|b| b.timestamp_ms))
}
/// A block's body, reduced to what is read from it.
///
/// One call for both facts. The ingest path wants only the timestamp and
/// the block page wants the extrinsic count too; fetching the same body
/// twice to answer them separately would double the load on a node that is
/// already mining.
///
/// `Ok(None)` for a block the node no longer holds — `blocks-pruning`
/// defaults to `archive-canonical`, so a losing fork's body goes once
/// finality passes it. That is an answer, not a failure.
pub async fn body(&self, hash: &str) -> Result<Option<BlockBody>, DataError> {
let v = self.call("chain_getBlock", json!([hash])).await?;
let first = v
.pointer("/block/extrinsics/0")
.and_then(Value::as_str)
.map(str::to_owned);
Ok(first
.as_deref()
.and_then(blackbeard_core::scale::timestamp_inherent_ms))
let Some(extrinsics) = v.pointer("/block/extrinsics").and_then(Value::as_array) else {
return Ok(None);
};
Ok(Some(BlockBody {
extrinsics: extrinsics.len() as u32,
// The timestamp inherent is always first. A body whose first
// extrinsic does not decode as one costs this block its timing and
// nothing else.
timestamp_ms: extrinsics
.first()
.and_then(Value::as_str)
.and_then(blackbeard_core::scale::timestamp_inherent_ms),
}))
}
/// Current mining difficulty: expected hashes to win a block.

View File

@@ -108,6 +108,29 @@ pub struct ChainRecord {
pub target_block_time: f64,
}
/// What the observer recorded about one block.
///
/// The fields the chain cannot supply after the fact: when we saw it, whether
/// that sighting was at the tip, and the difficulty read while the state
/// behind it still existed.
#[derive(Debug, Clone)]
pub struct RecordedBlock {
/// Block number.
pub height: u64,
/// Block hash as recorded.
pub hash: String,
/// Author's reward preimage.
pub miner: MinerId,
/// The author's own timestamp.
pub authored_at: Option<DateTime<Utc>>,
/// When this observer first saw it.
pub observed_at: DateTime<Utc>,
/// Difficulty as a decimal string.
pub difficulty: Option<String>,
/// Whether `observed_at` was a tip sighting.
pub at_tip: bool,
}
/// A held attribution loaded back from the database.
#[derive(Debug, Clone)]
pub struct StoredAttribution {
@@ -484,6 +507,92 @@ impl Store {
.collect())
}
/// What the observer recorded at one height, canonical as far as it knows.
pub async fn recorded_block(
&self,
chain: &ChainId,
height: u64,
) -> Result<Option<RecordedBlock>, DataError> {
let row = sqlx::query!(
r#"
select height, hash, miner, authored_at, observed_at,
difficulty::text as difficulty, at_tip
from block
where chain = $1 and height = $2
"#,
chain.as_str(),
height as i64,
)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| RecordedBlock {
height: r.height.max(0) as u64,
hash: r.hash,
miner: MinerId(r.miner),
authored_at: r.authored_at,
observed_at: r.observed_at,
difficulty: r.difficulty,
at_tip: r.at_tip,
}))
}
/// A block this observer recorded and a reorg later displaced.
///
/// The only way a losing block is describable at all once finality has
/// passed it: the node keeps no non-canonical body that long, so this row —
/// written at the moment of the swap — is the whole of what survives.
pub async fn displaced_block(
&self,
chain: &ChainId,
hash: &str,
) -> Result<Option<RecordedBlock>, DataError> {
let row = sqlx::query!(
r#"
select height, hash, miner, authored_at, observed_at,
difficulty::text as difficulty, at_tip
from block_displacement
where chain = $1 and hash = $2
"#,
chain.as_str(),
hash,
)
.fetch_optional(&self.pool)
.await?;
Ok(row.map(|r| RecordedBlock {
height: r.height.max(0) as u64,
hash: r.hash,
miner: MinerId(r.miner),
authored_at: r.authored_at,
observed_at: r.observed_at,
difficulty: r.difficulty,
at_tip: r.at_tip,
}))
}
/// Every hash this observer has seen hold a height, newest displacement
/// first. Empty for the overwhelming majority of heights, which were only
/// ever won once.
pub async fn hashes_seen_at(
&self,
chain: &ChainId,
height: u64,
) -> Result<Vec<String>, DataError> {
let rows = sqlx::query!(
r#"
select hash from block_displacement
where chain = $1 and height = $2
order by displaced_at desc
"#,
chain.as_str(),
height as i64,
)
.fetch_all(&self.pool)
.await?;
Ok(rows.into_iter().map(|r| r.hash).collect())
}
/// Persist the attributions currently held.
pub async fn save_attributions(
&self,

View File

@@ -4,7 +4,7 @@ use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use ts_rs::TS;
use crate::{BigUintDec, ChainId, MinerId};
use crate::{AttributionSource, BigUintDec, ChainId, MinerId};
/// A block as the observer recorded it.
///
@@ -58,3 +58,75 @@ pub struct RecentBlock {
/// subtract.
pub gap_seconds: Option<f64>,
}
/// One block, as completely as it can still be described.
///
/// Assembled from two sources that know different things, because neither is
/// sufficient on its own:
///
/// - The **node** holds the block itself — parent, body, and whether this hash
/// still holds its height. It forgets: `blocks-pruning` defaults to
/// `archive-canonical`, so a non-canonical body goes once finality passes it,
/// and difficulty is a state read behind a 256-block window.
/// - The **observer** holds what the node never had — when we first saw the
/// block, whether that sighting was at the tip — and what it has since
/// forgotten, the difficulty recorded while the state still existed.
///
/// Every field the pair cannot supply is `None` rather than a substituted
/// plausible value. A block page that quietly filled in the current difficulty
/// for one it could not look up would be the same lie the ingest path was fixed
/// to stop telling.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, TS)]
#[ts(export, export_to = "BlockDetail.ts")]
pub struct BlockDetail {
/// Which chain.
pub chain: ChainId,
/// Block number.
#[ts(type = "number")]
pub height: u64,
/// This block's hash — the identity the page is addressed by, so that a
/// link keeps meaning the same block after the height has moved on.
pub hash: String,
/// Parent hash, from the header.
pub parent_hash: Option<String>,
/// Author's reward preimage from the `pow_` PreRuntime digest.
pub miner: Option<MinerId>,
/// What to call the author: a telemetry node name, or the abbreviated
/// preimage. `None` where the author could not be decoded at all.
pub display: Option<String>,
/// Whether `display` is an inferred name or the preimage.
pub attribution: Option<AttributionSource>,
/// How much of the attribution vote window agrees, 0.01.0.
pub confidence: Option<f32>,
/// Expected hashes to win this block: the difficulty in force at its
/// parent, which is the figure the miner actually had to meet.
pub difficulty: Option<BigUintDec>,
/// The author's own timestamp, from the block's `Timestamp::set` inherent.
pub authored_at: Option<DateTime<Utc>>,
/// When this observer first saw the block. `None` for a block it never saw
/// at all — an orphan on a fork it missed, or history before it started.
pub observed_at: Option<DateTime<Utc>>,
/// Whether `observed_at` was a tip sighting. When false, the difference
/// between the timestamps measures when the observer got round to looking
/// and nothing about the network, so the UI must not read it as propagation.
pub at_tip: Option<bool>,
/// Seconds between this block's timestamp and its parent's — how long this
/// block took to find, against the chain's target.
pub seconds_since_parent: Option<f64>,
/// Extrinsics in the body, inherents included. `None` once the body has
/// been pruned.
pub extrinsics: Option<u32>,
/// Whether this hash is the one that currently holds this height.
pub canonical: bool,
/// The hash that currently holds this height. Equal to `hash` when
/// canonical; on an orphan page it is the link to the block that won.
pub canonical_hash: Option<String>,
/// Other hashes this observer has recorded at this height — the losing
/// sides of past reorgs. Navigable, which is the whole reason the
/// displacement is written down.
pub also_seen: Vec<String>,
/// False when the node could not be reached or no longer holds the block,
/// so everything here came from what the observer had already recorded.
/// The UI owes the reader that distinction rather than showing gaps.
pub from_node: bool,
}

View File

@@ -22,7 +22,7 @@ mod miner;
mod series;
mod ws;
pub use block::{BlockObservation, RecentBlock};
pub use block::{BlockDetail, BlockObservation, RecentBlock};
pub use chain::{ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, Tracking};
pub use error::{ApiError, EntityError};
pub use miner::{AttributionSource, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint};

View File

@@ -132,6 +132,23 @@ over three buckets, because the mean of one bucket's gaps still carries enough
Poisson noise to draw a chain that looks like it is changing size every few
minutes.
## A block has two addresses, and only one of them is stable
`#/planck/1069799` is a *position*. Because `block` is keyed on (chain, height)
and a reorg overwrites, a link to a height quietly comes to mean a different
block the moment the chain forks there. So opening one resolves it and rewrites
the address to `#/planck/0x…`, which names one block for good — including after
it has lost, which is the only way an orphan is linkable at all.
The page is assembled from both sources because neither is sufficient. The node
holds the block and forgets it: `blocks-pruning` defaults to
`archive-canonical`, so a losing fork's body goes once finality passes it, and
difficulty is a state read behind a 256-block window. The observer holds what
the node never had — when the block was seen, and whether that sighting was at
the tip — and what it has since forgotten, the difficulty recorded while the
state still existed. Anything neither can answer says so in words rather than
going blank, because a gap on a block page reads as a zero.
## Data
Postgres on `magrathea.kosherinata.internal`, mTLS and passwordless — the host's

View File

@@ -10,6 +10,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'
import type { Window as WindowName } from './api/generated/Window'
import { BlockPanel } from './components/BlockPanel'
import { BlockTicker } from './components/BlockTicker'
import { ChainSwitcher } from './components/ChainSwitcher'
import { Leaderboard } from './components/Leaderboard'
@@ -29,15 +30,33 @@ interface Route {
chain: string | null
window: WindowName
miner: string | null
block: string | null
}
/**
* A block number, or a 32-byte hash.
*
* The second path segment is a window (`day`, `six_hours`, …) or a block, and
* the two can never be confused: no window name is all digits or `0x` followed
* by 64 hex characters. That is what lets `#/planck/day` and
* `#/planck/1069799` share a shape without a discriminating prefix.
*/
function isBlockRef(segment: string): boolean {
return /^\d+$/.test(segment) || /^0x[0-9a-f]{64}$/i.test(segment)
}
function parseHash(hash: string): Route {
const parts = hash.replace(/^#\/?/, '').split('/').filter(Boolean)
if (parts[0] === 'miner' && parts[1]) {
return { chain: null, window: 'six_hours', miner: parts[1].toLowerCase() }
return { chain: null, window: 'six_hours', miner: parts[1].toLowerCase(), block: null }
}
const window = WINDOWS.find((w) => w.id === parts[1])?.id ?? 'six_hours'
return { chain: parts[0] ?? null, window, miner: null }
const chain = parts[0] ?? null
const second = parts[1]
if (second && isBlockRef(second)) {
return { chain, window: 'six_hours', miner: null, block: second.toLowerCase() }
}
const window = WINDOWS.find((w) => w.id === second)?.id ?? 'six_hours'
return { chain, window, miner: null, block: null }
}
export default function App() {
@@ -67,15 +86,38 @@ export default function App() {
const merged = { ...parseHash(window.location.hash), ...next }
window.location.hash = merged.miner
? `/miner/${merged.miner}`
: `/${merged.chain ?? ''}/${merged.window}`
: merged.block
? `/${merged.chain ?? ''}/${merged.block}`
: `/${merged.chain ?? ''}/${merged.window}`
}, [])
const selectMiner = useCallback((miner: string) => {
// Keep the chain and window; the miner panel opens above the board rather
// than replacing the page, so the standings stay visible behind it.
setRoute((r) => ({ ...r, miner }))
setRoute((r) => ({ ...r, miner, block: null }))
}, [])
const selectBlock = useCallback((block: string) => {
setRoute((r) => ({ ...r, block, miner: null }))
}, [])
/**
* A height has resolved to its hash; put the hash in the address bar.
*
* `replaceState`, not a navigation: it is the same block under the name that
* will still mean this block after a reorg, so it belongs in place of the
* height rather than on top of it in the back stack. Route state is left
* alone deliberately — rewriting it would re-render the panel with a new
* `block` prop and fetch the same block a second time.
*/
const blockResolved = useCallback(
(hash: string) => {
if (!chain) return
window.history.replaceState(null, '', `#/${chain}/${hash}`)
},
[chain],
)
const interval =
state.summary?.block_interval_seconds ?? state.summary?.target_block_time_seconds ?? 6
const activeWindow = WINDOWS.find((w) => w.id === route.window) ?? WINDOWS[1]!
@@ -100,7 +142,7 @@ export default function App() {
key={w.id}
aria-pressed={w.id === route.window}
title={`${w.blocks.toLocaleString('en-US')} blocks — ${windowSpan(w.blocks, interval)}`}
onClick={() => go({ window: w.id, miner: null })}
onClick={() => go({ window: w.id, miner: null, block: null })}
>
{w.label}
</button>
@@ -117,7 +159,7 @@ export default function App() {
<ChainSwitcher
chains={state.chains}
active={chain}
onSelect={(id) => go({ chain: id, miner: null })}
onSelect={(id) => go({ chain: id, miner: null, block: null })}
/>
{info?.status === 'awaiting' && (
@@ -141,6 +183,17 @@ export default function App() {
<StatBar summary={state.summary} windowName={route.window} />
{route.block && chain && (
<BlockPanel
chain={chain}
block={route.block}
targetSeconds={info?.target_block_time_seconds ?? 6}
onResolved={blockResolved}
onSelectMiner={selectMiner}
onClose={() => go({ window: route.window, miner: null, block: null })}
/>
)}
{route.miner && chain && (
<MinerPanel
chain={chain}
@@ -172,7 +225,12 @@ export default function App() {
: 'measuring'}
</span>
</div>
<BlockTicker blocks={state.blocks} onSelect={selectMiner} />
<BlockTicker
blocks={state.blocks}
chain={chain}
onSelect={selectMiner}
onSelectBlock={selectBlock}
/>
</section>
</div>

View File

@@ -0,0 +1,111 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AttributionSource } from "./AttributionSource";
import type { BigUintDec } from "./BigUintDec";
import type { ChainId } from "./ChainId";
import type { MinerId } from "./MinerId";
/**
* One block, as completely as it can still be described.
*
* Assembled from two sources that know different things, because neither is
* sufficient on its own:
*
* - The **node** holds the block itself — parent, body, and whether this hash
* still holds its height. It forgets: `blocks-pruning` defaults to
* `archive-canonical`, so a non-canonical body goes once finality passes it,
* and difficulty is a state read behind a 256-block window.
* - The **observer** holds what the node never had — when we first saw the
* block, whether that sighting was at the tip — and what it has since
* forgotten, the difficulty recorded while the state still existed.
*
* Every field the pair cannot supply is `None` rather than a substituted
* plausible value. A block page that quietly filled in the current difficulty
* for one it could not look up would be the same lie the ingest path was fixed
* to stop telling.
*/
export type BlockDetail = {
/**
* Which chain.
*/
chain: ChainId,
/**
* Block number.
*/
height: number,
/**
* This block's hash — the identity the page is addressed by, so that a
* link keeps meaning the same block after the height has moved on.
*/
hash: string,
/**
* Parent hash, from the header.
*/
parent_hash: string | null,
/**
* Author's reward preimage from the `pow_` PreRuntime digest.
*/
miner: MinerId | null,
/**
* What to call the author: a telemetry node name, or the abbreviated
* preimage. `None` where the author could not be decoded at all.
*/
display: string | null,
/**
* Whether `display` is an inferred name or the preimage.
*/
attribution: AttributionSource | null,
/**
* How much of the attribution vote window agrees, 0.01.0.
*/
confidence: number | null,
/**
* Expected hashes to win this block: the difficulty in force at its
* parent, which is the figure the miner actually had to meet.
*/
difficulty: BigUintDec | null,
/**
* The author's own timestamp, from the block's `Timestamp::set` inherent.
*/
authored_at: string | null,
/**
* When this observer first saw the block. `None` for a block it never saw
* at all — an orphan on a fork it missed, or history before it started.
*/
observed_at: string | null,
/**
* Whether `observed_at` was a tip sighting. When false, the difference
* between the timestamps measures when the observer got round to looking
* and nothing about the network, so the UI must not read it as propagation.
*/
at_tip: boolean | null,
/**
* Seconds between this block's timestamp and its parent's — how long this
* block took to find, against the chain's target.
*/
seconds_since_parent: number | null,
/**
* Extrinsics in the body, inherents included. `None` once the body has
* been pruned.
*/
extrinsics: number | null,
/**
* Whether this hash is the one that currently holds this height.
*/
canonical: boolean,
/**
* The hash that currently holds this height. Equal to `hash` when
* canonical; on an orphan page it is the link to the block that won.
*/
canonical_hash: string | null,
/**
* Other hashes this observer has recorded at this height — the losing
* sides of past reorgs. Navigable, which is the whole reason the
* displacement is written down.
*/
also_seen: Array<string>,
/**
* False when the node could not be reached or no longer holds the block,
* so everything here came from what the observer had already recorded.
* The UI owes the reader that distinction rather than showing gaps.
*/
from_node: boolean, };

View File

@@ -7,6 +7,7 @@
*/
import type { ApiError } from './generated/ApiError'
import type { BlockDetail } from './generated/BlockDetail'
import type { ChainSeries } from './generated/ChainSeries'
import type { MinerDetail } from './generated/MinerDetail'
import type { Window as WindowName } from './generated/Window'
@@ -59,6 +60,24 @@ export function fetchSeries(
)
}
/**
* One block, by height or by hash.
*
* A height names whatever currently holds it and a hash names one block for
* good, so the caller redirects to the hash once the height resolves — see
* `BlockPanel`. The API accepts either spelling at the same path.
*/
export function fetchBlock(
chain: string,
block: string,
signal?: AbortSignal,
): Promise<BlockDetail> {
return get<BlockDetail>(
`/chains/${encodeURIComponent(chain)}/blocks/${encodeURIComponent(block)}`,
signal,
)
}
/** One miner's standing and history. */
export function fetchMiner(
chain: string,

View File

@@ -0,0 +1,267 @@
/**
* One block.
*
* Opened from a height — the number in the ticker, or a URL somebody typed —
* and then it rewrites the address to the block's hash. That is not tidiness:
* `block` is keyed on (chain, height) and a reorg overwrites, so a height names
* a *position* and a link to one quietly comes to mean a different block the
* moment the chain forks there. A hash names one block for good, including
* after it has lost.
*
* The page says where each fact came from, because the two sources fail in
* different ways and at different times. The node holds the block and forgets
* it — non-canonical bodies go once finality passes them, and difficulty is a
* state read behind a 256-block window. The observer holds what the node never
* had, the sighting times, and what it has since forgotten. A blank here means
* neither could answer, never that the value is zero.
*/
import { useEffect, useState } from 'react'
import type { BlockDetail } from '../api/generated/BlockDetail'
import { RequestFailed, fetchBlock } from '../api/rest'
import { ago, bigNumber, height as fmtHeight, seconds, shortMiner } from '../lib/format'
/** A label and its value, with the caveat that makes the value readable. */
function Fact({
label,
children,
note,
}: {
label: string
children: React.ReactNode
// `| undefined` explicitly: the project sets `exactOptionalPropertyTypes`, so
// an optional prop does not otherwise accept a conditional that may be
// undefined — which is exactly how every caller here computes its caveat.
note?: string | undefined
}) {
return (
<div className="fact">
<div className="fact-label">{label}</div>
<div className="fact-value">{children}</div>
{note && <div className="fact-note">{note}</div>}
</div>
)
}
/** A value the observer does not have, said plainly rather than left blank. */
function Unknown({ why }: { why: string }) {
return <span className="fact-unknown">not recorded {why}</span>
}
export function BlockPanel({
chain,
block,
targetSeconds,
onResolved,
onSelectMiner,
onClose,
}: {
chain: string
/** Height or hash, as it appeared in the URL. */
block: string
/** The chain's target block time, for reading the gap against. */
targetSeconds: number
/** Called with the canonical hash once a height has resolved to one. */
onResolved: (hash: string) => void
onSelectMiner: (miner: string) => void
onClose: () => void
}) {
const [detail, setDetail] = useState<BlockDetail | null>(null)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
const controller = new AbortController()
setDetail(null)
setError(null)
fetchBlock(chain, block, controller.signal)
.then((d) => {
setDetail(d)
// Swap the address for the immutable one. Only ever a replace: this is
// the same block under a better name, not somewhere the reader went.
if (d.hash !== block) onResolved(d.hash)
})
.catch((e: unknown) => {
if (controller.signal.aborted) return
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
})
return () => controller.abort()
}, [chain, block, onResolved])
// Propagation: the author stamps its timestamp when it *builds* the proposal,
// so this is that moment to the moment we saw the block. Only a number at all
// when the sighting was at the tip — on a gap fill it measures when the
// observer got round to looking, which is a fact about us.
const propagationMs =
detail?.at_tip && detail.authored_at && detail.observed_at
? new Date(detail.observed_at).getTime() - new Date(detail.authored_at).getTime()
: null
return (
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<div>
<div className="eyebrow">Block</div>
<h2 className="panel-title" style={{ marginTop: 4 }}>
{detail
? `#${fmtHeight(detail.height)}`
: block.startsWith('0x')
? 'Block'
: `#${block}`}
{detail && !detail.canonical && <span className="badge badge-warn">replaced</span>}
</h2>
<div
className="numeral"
style={{
fontSize: 11,
color: 'var(--text-muted)',
marginTop: 4,
overflowWrap: 'anywhere',
}}
>
{detail?.hash ?? ''}
</div>
</div>
<button
className="segmented"
style={{
padding: '7px 13px',
font: 'inherit',
fontSize: 12,
letterSpacing: '0.1em',
textTransform: 'uppercase',
color: 'var(--text-secondary)',
cursor: 'pointer',
}}
onClick={onClose}
>
Close
</button>
</div>
{error && <p className="empty">{error}</p>}
{!error && !detail && <p className="empty">Reading the block</p>}
{detail && (
<>
{!detail.canonical && (
<div className="banner banner-warn" style={{ margin: 0, borderWidth: '0 0 1px' }}>
This block lost its height to a reorg. It is kept here because the observer saw it
the chain itself no longer serves losing blocks for long.{' '}
{detail.canonical_hash && (
<a href={`#/${chain}/${detail.canonical_hash}`}>See the block that won </a>
)}
</div>
)}
{!detail.from_node && (
<div className="banner banner-warn" style={{ margin: 0, borderWidth: '0 0 1px' }}>
The node could not answer for this block, so everything below is what the observer
recorded at the time. The body, the parent and the timing against it are missing
because only the chain ever had them.
</div>
)}
<div className="facts">
<Fact label="Author" note={detail.confidence ? undefined : 'no telemetry name held'}>
{detail.miner ? (
<a
href={`#/miner/${detail.miner}`}
title={detail.miner}
onClick={(e) => {
e.preventDefault()
onSelectMiner(detail.miner as string)
}}
>
{detail.display ?? shortMiner(detail.miner)}
</a>
) : (
// Genesis carries no `pow_` digest, and neither would a header
// shape this decoder does not know.
<Unknown why="this header carries no author digest" />
)}
</Fact>
<Fact label="Difficulty" note="expected hashes to win it">
{detail.difficulty ? (
<span title={detail.difficulty}>{bigNumber(detail.difficulty)}</span>
) : (
<Unknown why="the node's state for this height is pruned" />
)}
</Fact>
<Fact
label="Time to find"
note={
detail.seconds_since_parent != null ? `target ${seconds(targetSeconds)}` : undefined
}
>
{detail.seconds_since_parent != null ? (
seconds(detail.seconds_since_parent)
) : (
<Unknown why="the parent's timestamp is unavailable" />
)}
</Fact>
<Fact label="Extrinsics" note="inherents included">
{detail.extrinsics != null ? (
String(detail.extrinsics)
) : (
<Unknown why="the body is pruned" />
)}
</Fact>
<Fact label="Authored" note={detail.authored_at ? "the author's own clock" : undefined}>
{detail.authored_at ? (
<span title={detail.authored_at}>{ago(detail.authored_at)}</span>
) : (
<Unknown why="no timestamp inherent was decoded" />
)}
</Fact>
<Fact
label="Seen here"
note={
propagationMs != null
? `${propagationMs} ms after it was authored`
: detail.observed_at
? 'caught up from history, not seen live'
: undefined
}
>
{detail.observed_at ? (
<span title={detail.observed_at}>{ago(detail.observed_at)}</span>
) : (
<Unknown why="this observer never saw this block" />
)}
</Fact>
</div>
<div className="block-links">
{detail.parent_hash && (
<div>
<span className="eyebrow">Parent</span>{' '}
<a className="numeral" href={`#/${chain}/${detail.parent_hash}`}>
{detail.parent_hash}
</a>
</div>
)}
{detail.also_seen.length > 0 && (
<div>
<span className="eyebrow">
Also seen at this height ({detail.also_seen.length})
</span>
{detail.also_seen.map((h) => (
<div key={h}>
<a className="numeral" href={`#/${chain}/${h}`}>
{h}
</a>
</div>
))}
</div>
)}
</div>
</>
)}
</section>
)
}

View File

@@ -15,10 +15,15 @@ import { usePinnedMiners } from '../lib/store'
export function BlockTicker({
blocks,
chain,
onSelect,
onSelectBlock,
}: {
blocks: RecentBlock[]
/** Needed to address a block; null before the first chain has resolved. */
chain: string | null
onSelect: (miner: string) => void
onSelectBlock: (block: string) => void
}) {
const { isPinned } = usePinnedMiners()
const seen = useRef<Set<number>>(new Set())
@@ -54,7 +59,20 @@ export function BlockTicker({
.filter(Boolean)
.join(' ')}
>
<span className="ticker-height">#{height(block.height)}</span>
{chain ? (
<a
className="ticker-height"
href={`#/${chain}/${block.height}`}
onClick={(e) => {
e.preventDefault()
onSelectBlock(String(block.height))
}}
>
#{height(block.height)}
</a>
) : (
<span className="ticker-height">#{height(block.height)}</span>
)}
<a
href={`#/miner/${block.miner}`}
title={block.miner}

View File

@@ -469,6 +469,111 @@ a {
stroke-width: 1;
}
/* ---- block facts --------------------------------------------------------- */
/* The same grid as the stat tiles: this is the same kind of content — a handful
of single values with a caveat each — and giving it a second look would say
the two are different sorts of fact when they are not. */
.facts {
display: grid;
/* Three, not auto-fit. There are exactly six facts, and auto-fit resolves to
five at this width — leaving one on a row of its own beside four empty
cells that read as a grid that failed to load. Six divides evenly by three
and by two, so every breakpoint below is a full rectangle. A seventh fact
would need this revisited, which is the right amount of friction. */
grid-template-columns: repeat(3, 1fr);
gap: 1px;
background: var(--border);
border-bottom: var(--rule);
}
@media (max-width: 760px) {
.facts {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 460px) {
.facts {
grid-template-columns: 1fr;
}
}
.fact {
background: var(--surface-1);
padding: 14px 18px 15px;
min-width: 0;
}
.fact-label {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.15em;
text-transform: uppercase;
color: var(--text-muted);
}
.fact-value {
font-family: var(--font-mono);
font-size: 17px;
margin-top: 7px;
overflow-wrap: anywhere;
}
.fact-note {
font-size: 11px;
color: var(--text-muted);
margin-top: 5px;
}
/* A value nobody has. Muted and in the body face so it does not read as data —
it is a sentence explaining an absence, and formatting it like a figure would
invite it to be compared with one. */
.fact-unknown {
font-family: var(--font-body);
font-size: 12px;
color: var(--text-muted);
}
.block-links {
padding: 15px 18px 18px;
display: grid;
gap: 12px;
font-size: 12px;
}
.block-links a {
color: var(--text-secondary);
overflow-wrap: anywhere;
}
.block-links a:hover {
color: var(--data-bright);
}
/* The one place the accent hue belongs: not a series, a state. A block that
lost its height is the single most important thing its page can say. */
.badge {
margin-left: 10px;
padding: 2px 7px;
font-family: var(--font-body);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
vertical-align: middle;
}
.badge-warn {
background: var(--accent-wash);
color: var(--accent-bright);
border: 1px solid var(--accent);
}
a.ticker-height:hover {
color: var(--data-bright);
}
/* ---- leaderboard --------------------------------------------------------- */
.board {