From 53dac3717bf3ccf2de348ed54dae69a1700132ac Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Tue, 8 Sep 2026 16:30:41 +0300 Subject: [PATCH] feat: block explorer routes, addressed by hash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `#/: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) Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 --- ...ccb9837537c0b2b8348742bb08d3d211cec04.json | 59 ++++ ...3ba57b2ee5c9f6d24ae5f914eef45136ae24b.json | 59 ++++ ...8c5866f11f191ff1bb44dfbd03cb78d36e963.json | 23 ++ CLAUDE.md | 9 + crates/blackbeard-api/src/routes.rs | 283 +++++++++++++++++- crates/blackbeard-api/src/state.rs | 18 +- crates/blackbeard-data/src/rpc.rs | 43 ++- crates/blackbeard-data/src/store.rs | 109 +++++++ crates/blackbeard-entities/src/block.rs | 74 ++++- crates/blackbeard-entities/src/lib.rs | 2 +- readme.md | 17 ++ web/src/App.tsx | 74 ++++- web/src/api/generated/BlockDetail.ts | 111 +++++++ web/src/api/rest.ts | 19 ++ web/src/components/BlockPanel.tsx | 267 +++++++++++++++++ web/src/components/BlockTicker.tsx | 20 +- web/src/index.css | 105 +++++++ 17 files changed, 1270 insertions(+), 22 deletions(-) create mode 100644 .sqlx/query-48ec2e94c55c11aadc8baefd787ccb9837537c0b2b8348742bb08d3d211cec04.json create mode 100644 .sqlx/query-6faa701a33e2851503d3ab891e13ba57b2ee5c9f6d24ae5f914eef45136ae24b.json create mode 100644 .sqlx/query-e45a56833ce1558f0f76061b7fc8c5866f11f191ff1bb44dfbd03cb78d36e963.json create mode 100644 web/src/api/generated/BlockDetail.ts create mode 100644 web/src/components/BlockPanel.tsx diff --git a/.sqlx/query-48ec2e94c55c11aadc8baefd787ccb9837537c0b2b8348742bb08d3d211cec04.json b/.sqlx/query-48ec2e94c55c11aadc8baefd787ccb9837537c0b2b8348742bb08d3d211cec04.json new file mode 100644 index 0000000..8a0db00 --- /dev/null +++ b/.sqlx/query-48ec2e94c55c11aadc8baefd787ccb9837537c0b2b8348742bb08d3d211cec04.json @@ -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" +} diff --git a/.sqlx/query-6faa701a33e2851503d3ab891e13ba57b2ee5c9f6d24ae5f914eef45136ae24b.json b/.sqlx/query-6faa701a33e2851503d3ab891e13ba57b2ee5c9f6d24ae5f914eef45136ae24b.json new file mode 100644 index 0000000..0bd47b1 --- /dev/null +++ b/.sqlx/query-6faa701a33e2851503d3ab891e13ba57b2ee5c9f6d24ae5f914eef45136ae24b.json @@ -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" +} diff --git a/.sqlx/query-e45a56833ce1558f0f76061b7fc8c5866f11f191ff1bb44dfbd03cb78d36e963.json b/.sqlx/query-e45a56833ce1558f0f76061b7fc8c5866f11f191ff1bb44dfbd03cb78d36e963.json new file mode 100644 index 0000000..edb980d --- /dev/null +++ b/.sqlx/query-e45a56833ce1558f0f76061b7fc8c5866f11f191ff1bb44dfbd03cb78d36e963.json @@ -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" +} diff --git a/CLAUDE.md b/CLAUDE.md index d2ea4b3..36e571c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index 67b66ae..decb829 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -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, + Path((chain, reference)): Path<(String, String)>, +) -> Result, 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 { + if let Ok(height) = raw.parse::() { + 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 { + 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 { + 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, +) -> 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 { + 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::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"; diff --git a/crates/blackbeard-api/src/state.rs b/crates/blackbeard-api/src/state.rs index 329ea51..0efa505 100644 --- a/crates/blackbeard-api/src/state.rs +++ b/crates/blackbeard-api/src/state.rs @@ -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 { self.read().ticker.iter().cloned().collect() diff --git a/crates/blackbeard-data/src/rpc.rs b/crates/blackbeard-data/src/rpc.rs index 5916ef0..f1397dc 100644 --- a/crates/blackbeard-data/src/rpc.rs +++ b/crates/blackbeard-data/src/rpc.rs @@ -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, +} + /// 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, 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, 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. diff --git a/crates/blackbeard-data/src/store.rs b/crates/blackbeard-data/src/store.rs index 1359556..8c8d931 100644 --- a/crates/blackbeard-data/src/store.rs +++ b/crates/blackbeard-data/src/store.rs @@ -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>, + /// When this observer first saw it. + pub observed_at: DateTime, + /// Difficulty as a decimal string. + pub difficulty: Option, + /// 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, 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, 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, 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, diff --git a/crates/blackbeard-entities/src/block.rs b/crates/blackbeard-entities/src/block.rs index 98eff6b..42bf3ce 100644 --- a/crates/blackbeard-entities/src/block.rs +++ b/crates/blackbeard-entities/src/block.rs @@ -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, } + +/// 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, + /// Author's reward preimage from the `pow_` PreRuntime digest. + pub miner: Option, + /// 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, + /// Whether `display` is an inferred name or the preimage. + pub attribution: Option, + /// How much of the attribution vote window agrees, 0.0–1.0. + pub confidence: Option, + /// 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, + /// The author's own timestamp, from the block's `Timestamp::set` inherent. + pub authored_at: Option>, + /// 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>, + /// 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, + /// 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, + /// Extrinsics in the body, inherents included. `None` once the body has + /// been pruned. + pub extrinsics: Option, + /// 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, + /// 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, + /// 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, +} diff --git a/crates/blackbeard-entities/src/lib.rs b/crates/blackbeard-entities/src/lib.rs index 8f5db70..f7e1863 100644 --- a/crates/blackbeard-entities/src/lib.rs +++ b/crates/blackbeard-entities/src/lib.rs @@ -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}; diff --git a/readme.md b/readme.md index 9d22c9d..981c910 100644 --- a/readme.md +++ b/readme.md @@ -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 diff --git a/web/src/App.tsx b/web/src/App.tsx index c5343fc..3df6f67 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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} @@ -117,7 +159,7 @@ export default function App() { 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() { + {route.block && chain && ( + go({ window: route.window, miner: null, block: null })} + /> + )} + {route.miner && chain && ( - + diff --git a/web/src/api/generated/BlockDetail.ts b/web/src/api/generated/BlockDetail.ts new file mode 100644 index 0000000..511447f --- /dev/null +++ b/web/src/api/generated/BlockDetail.ts @@ -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.0–1.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, +/** + * 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, }; diff --git a/web/src/api/rest.ts b/web/src/api/rest.ts index 910cb89..adf312a 100644 --- a/web/src/api/rest.ts +++ b/web/src/api/rest.ts @@ -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 { + return get( + `/chains/${encodeURIComponent(chain)}/blocks/${encodeURIComponent(block)}`, + signal, + ) +} + /** One miner's standing and history. */ export function fetchMiner( chain: string, diff --git a/web/src/components/BlockPanel.tsx b/web/src/components/BlockPanel.tsx new file mode 100644 index 0000000..7ad1ddd --- /dev/null +++ b/web/src/components/BlockPanel.tsx @@ -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 ( +
+
{label}
+
{children}
+ {note &&
{note}
} +
+ ) +} + +/** A value the observer does not have, said plainly rather than left blank. */ +function Unknown({ why }: { why: string }) { + return not recorded — {why} +} + +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(null) + const [error, setError] = useState(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 ( +
+
+
+
Block
+

+ {detail + ? `#${fmtHeight(detail.height)}` + : block.startsWith('0x') + ? 'Block' + : `#${block}`} + {detail && !detail.canonical && replaced} +

+
+ {detail?.hash ?? ''} +
+
+ +
+ + {error &&

{error}

} + {!error && !detail &&

Reading the block…

} + + {detail && ( + <> + {!detail.canonical && ( +
+ 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 && ( + See the block that won → + )} +
+ )} + {!detail.from_node && ( +
+ 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. +
+ )} + +
+ + {detail.miner ? ( + { + e.preventDefault() + onSelectMiner(detail.miner as string) + }} + > + {detail.display ?? shortMiner(detail.miner)} + + ) : ( + // Genesis carries no `pow_` digest, and neither would a header + // shape this decoder does not know. + + )} + + + + {detail.difficulty ? ( + {bigNumber(detail.difficulty)} + ) : ( + + )} + + + + {detail.seconds_since_parent != null ? ( + seconds(detail.seconds_since_parent) + ) : ( + + )} + + + + {detail.extrinsics != null ? ( + String(detail.extrinsics) + ) : ( + + )} + + + + {detail.authored_at ? ( + {ago(detail.authored_at)} + ) : ( + + )} + + + + {detail.observed_at ? ( + {ago(detail.observed_at)} + ) : ( + + )} + +
+ +
+ {detail.parent_hash && ( +
+ Parent{' '} + + {detail.parent_hash} + +
+ )} + {detail.also_seen.length > 0 && ( +
+ + Also seen at this height ({detail.also_seen.length}) + + {detail.also_seen.map((h) => ( + + ))} +
+ )} +
+ + )} +
+ ) +} diff --git a/web/src/components/BlockTicker.tsx b/web/src/components/BlockTicker.tsx index 4bac369..a35ae2d 100644 --- a/web/src/components/BlockTicker.tsx +++ b/web/src/components/BlockTicker.tsx @@ -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>(new Set()) @@ -54,7 +59,20 @@ export function BlockTicker({ .filter(Boolean) .join(' ')} > - #{height(block.height)} + {chain ? ( + { + e.preventDefault() + onSelectBlock(String(block.height)) + }} + > + #{height(block.height)} + + ) : ( + #{height(block.height)} + )}