diff --git a/.sqlx/query-525f21399582e40b79567a370e5ef052670f46ac12a413c6636bc06c720f9935.json b/.sqlx/query-525f21399582e40b79567a370e5ef052670f46ac12a413c6636bc06c720f9935.json new file mode 100644 index 0000000..ffeffbf --- /dev/null +++ b/.sqlx/query-525f21399582e40b79567a370e5ef052670f46ac12a413c6636bc06c720f9935.json @@ -0,0 +1,48 @@ +{ + "db_name": "PostgreSQL", + "query": "\n select height, miner, observed_at, authored_at, difficulty\n from block\n where chain = $1 and height < $2\n order by height desc\n limit $3\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "height", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "miner", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "observed_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "authored_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "difficulty", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + true, + true + ] + }, + "hash": "525f21399582e40b79567a370e5ef052670f46ac12a413c6636bc06c720f9935" +} diff --git a/.sqlx/query-914725a46ee04bd2655fd22c329a981b848f944edec94a5c50624fddae910e68.json b/.sqlx/query-914725a46ee04bd2655fd22c329a981b848f944edec94a5c50624fddae910e68.json new file mode 100644 index 0000000..81b8f43 --- /dev/null +++ b/.sqlx/query-914725a46ee04bd2655fd22c329a981b848f944edec94a5c50624fddae910e68.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "\n select a.account as \"account!\",\n count(*) as \"blocks!\",\n sum((e.fields->>'reward')::numeric)::text as \"total!\",\n -- Any of them: the wormhole derivation runs one way from a\n -- preimage to an address, so every reward paid to one\n -- account came from the same preimage. `max` is how to say\n -- \"any non-null\" in a group by, not a choice between\n -- candidates. Null when no reward height has a block row —\n -- events are indexed from genesis, blocks only from when\n -- this observer started watching.\n max(b.miner) as miner\n from chain_event e\n cross join unnest(e.accounts) a(account)\n left join block b on b.chain = e.chain and b.height = e.height\n where e.chain = $1\n and e.pallet = 'MiningRewards' and e.variant = 'MinerRewarded'\n group by a.account\n order by sum((e.fields->>'reward')::numeric) desc\n limit $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "account!", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "blocks!", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "total!", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "miner", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [ + null, + null, + null, + null + ] + }, + "hash": "914725a46ee04bd2655fd22c329a981b848f944edec94a5c50624fddae910e68" +} diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index 42907d1..67ddffb 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::{ - AccountDetail, AccountEvent, ApiError, BigUintDec, BlockDetail, ChainInfo, ChainSeries, - ChainSummary, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint, RecentBlock, + AccountDetail, AccountEvent, AccountRow, ApiError, BigUintDec, BlockDetail, ChainInfo, + ChainSeries, ChainSummary, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint, RecentBlock, RewardSummary, RuntimeConstant, RuntimeDetail, RuntimeField, RuntimePallet, RuntimeSignedExtension, RuntimeStorage, RuntimeSummary, RuntimeVariant, Window, }; @@ -52,6 +52,7 @@ pub fn router(state: AppState, allowed_origins: &[String]) -> Router { .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/chains/{chain}/accounts", get(accounts)) .route("/v1/chains/{chain}/accounts/{address}", get(account)) .route("/v1/chains/{chain}/runtimes", get(runtimes)) .route("/v1/chains/{chain}/runtimes/{spec}", get(runtime)) @@ -189,9 +190,50 @@ async fn leaderboard( async fn blocks( State(state): State, Path(chain): Path, + Query(query): Query, ) -> Result>, Failure> { let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?; - Ok(Json(runtime.ticker())) + // No cursor means "the newest", which the in-memory ticker already is and + // answers without touching the database — it is what every socket + // bootstrap asks for. A cursor means "before that", which only the record + // has. Same question, two sources, and the boundary between them is + // whether the caller is at the front of the list. + let Some(before) = query.before else { + return Ok(Json(runtime.ticker())); + }; + + let observed = state + .store + .blocks_before(&runtime.id(), Some(before), INDEX_PAGE) + .await + .map_err(database_unavailable)?; + + let mut out = Vec::with_capacity(observed.len()); + for (i, o) in observed.iter().enumerate() { + let attributed = runtime.attribute(&o.miner); + out.push(RecentBlock { + height: o.height, + miner: o.miner.clone(), + display: attributed.display, + attribution: attributed.source, + observed_at: o.observed_at, + // The chain's clock, not ours. `observed_at` is a measurement only + // at the tip (CLAUDE.md, and the `at_tip` column exists for it): a + // gap fill writes a whole batch within the same second, so + // differencing it here reported three *milliseconds* between blocks + // on a chain targeting twelve seconds. Authored times are what the + // authors stamped, which is the question a history is asking. + // + // Against the next row down, which is the previous block — the rows + // are newest first. `None` at the end of the page, where the + // predecessor is on the page after it rather than absent, and + // `None` for a block recorded before `authored_at` was captured. + gap_seconds: observed + .get(i + 1) + .and_then(|p| Some((o.authored_at? - p.authored_at?).as_seconds_f64())), + }); + } + Ok(Json(out)) } /// `GET /v1/chains/{chain}/blocks/{block}` @@ -559,6 +601,65 @@ async fn miner( })) } +/// How many rows an index page carries. +const INDEX_PAGE: i64 = 50; + +/// Paging cursor for the block index. +#[derive(Debug, Deserialize)] +struct BeforeQuery { + before: Option, +} + +/// Accounts ranked by what they have earned, most first. +/// +/// The standings answer "who is winning blocks now". This answers "who has been +/// paid the most", which on a chain whose difficulty rose 346-fold inside its +/// first day is emphatically not the same list. +async fn accounts( + State(state): State, + Path(chain): Path, +) -> Result>, Failure> { + let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?; + let ranked = state + .store + .top_accounts(&runtime.id(), INDEX_PAGE) + .await + .map_err(database_unavailable)?; + + Ok(Json( + ranked + .into_iter() + .enumerate() + .map(|(i, r)| { + // Only a real name. `attribute` falls back to the abbreviated + // preimage, which beside the address in the same row would be + // a second spelling of the same thing dressed as an identity. + let held = r.miner.as_ref().map(|m| runtime.attribute(m)); + let display = held.as_ref().and_then(|h| match h.source { + blackbeard_entities::AttributionSource::Preimage => None, + _ => Some(h.display.clone()), + }); + AccountRow { + rank: i as u32 + 1, + attribution: held + .as_ref() + .map(|h| h.source) + .unwrap_or(blackbeard_entities::AttributionSource::Preimage), + confidence: held.as_ref().map(|h| h.confidence).unwrap_or(0.0), + attribution_votes: held.as_ref().map(|h| h.votes).unwrap_or(0), + address: blackbeard_core::wormhole::ss58_of(&r.account) + .unwrap_or_else(|| r.account.clone()), + account: r.account, + miner: r.miner, + display, + blocks: r.blocks, + total: BigUintDec(r.total), + } + }) + .collect(), + )) +} + /// Every runtime this chain has been seen running, oldest first. async fn runtimes( State(state): State, diff --git a/crates/blackbeard-data/src/store.rs b/crates/blackbeard-data/src/store.rs index 858203f..3a645a7 100644 --- a/crates/blackbeard-data/src/store.rs +++ b/crates/blackbeard-data/src/store.rs @@ -180,6 +180,19 @@ pub struct StoredEvent { pub fields: serde_json::Value, } +/// One account's mining income, for the ranked list. +#[derive(Debug, Clone)] +pub struct AccountRanking { + /// The raw account id, `0x` hex. + pub account: String, + /// How many blocks it was paid for. + pub blocks: u64, + /// Their sum, in the chain's smallest unit, as a decimal string. + pub total: String, + /// The reward preimage behind it, when a block row names one. + pub miner: Option, +} + /// One runtime version this chain has run, as cached. #[derive(Debug, Clone)] pub struct CachedRuntime { @@ -461,6 +474,103 @@ impl Store { .collect()) } + /// Blocks below `before`, newest first — the paged history behind the + /// block index. + /// + /// Deliberately not `recent_blocks` with an offset: blocks arrive at the + /// front of this ordering while somebody is paging through it, and an + /// offset would show them a row twice. The height is the cursor because it + /// is already the key. + pub async fn blocks_before( + &self, + chain: &ChainId, + before: Option, + limit: i64, + ) -> Result, DataError> { + // `i64::MAX` rather than a second query for the first page: every real + // height is under it, so "before everything" is the same statement. + let before = before.map(|h| h as i64).unwrap_or(i64::MAX); + let rows = sqlx::query!( + r#" + select height, miner, observed_at, authored_at, difficulty + from block + where chain = $1 and height < $2 + order by height desc + limit $3 + "#, + chain.as_str(), + before, + limit, + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .into_iter() + .map(|r| Observed { + height: r.height as u64, + miner: MinerId(r.miner), + observed_at: r.observed_at, + authored_at: r.authored_at, + difficulty: r + .difficulty + .as_ref() + .and_then(|d| d.to_string().parse().ok()), + }) + .collect()) + } + + /// Accounts by what they have been paid, most first. + /// + /// The standings ranked by tokens rather than by blocks, which is a + /// different question and a different answer: difficulty rose 346-fold + /// inside mainnet's first day, so a miner that won early holds blocks that + /// cost a three-hundredth of today's and was paid accordingly. + /// + /// Over the whole indexed record rather than a window — an account page is + /// about a balance, and a balance has no window. + pub async fn top_accounts( + &self, + chain: &ChainId, + limit: i64, + ) -> Result, DataError> { + let rows = sqlx::query!( + r#" + select a.account as "account!", + count(*) as "blocks!", + sum((e.fields->>'reward')::numeric)::text as "total!", + -- Any of them: the wormhole derivation runs one way from a + -- preimage to an address, so every reward paid to one + -- account came from the same preimage. `max` is how to say + -- "any non-null" in a group by, not a choice between + -- candidates. Null when no reward height has a block row — + -- events are indexed from genesis, blocks only from when + -- this observer started watching. + max(b.miner) as miner + from chain_event e + cross join unnest(e.accounts) a(account) + left join block b on b.chain = e.chain and b.height = e.height + where e.chain = $1 + and e.pallet = 'MiningRewards' and e.variant = 'MinerRewarded' + group by a.account + order by sum((e.fields->>'reward')::numeric) desc + limit $2 + "#, + chain.as_str(), + limit, + ) + .fetch_all(&self.pool) + .await?; + Ok(rows + .into_iter() + .map(|r| AccountRanking { + account: r.account, + blocks: r.blocks.max(0) as u64, + total: r.total, + miner: r.miner.map(MinerId), + }) + .collect()) + } + /// The last `limit` blocks with their tip flag, newest **first**. /// /// Feeds the two things a restart otherwise loses: the block ticker, which diff --git a/crates/blackbeard-entities/src/account.rs b/crates/blackbeard-entities/src/account.rs index 26e23e8..911f47b 100644 --- a/crates/blackbeard-entities/src/account.rs +++ b/crates/blackbeard-entities/src/account.rs @@ -13,7 +13,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use ts_rs::TS; -use crate::{BigUintDec, ChainId, MinerId}; +use crate::{AttributionSource, BigUintDec, ChainId, MinerId}; /// Everything the observer holds about one account. #[derive(Debug, Clone, Serialize, Deserialize, TS)] @@ -52,6 +52,42 @@ pub struct AccountDetail { pub indexed_to: Option, } +/// One row of the accounts index: an account and what it has earned. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "AccountRow.ts")] +pub struct AccountRow { + /// Position in the ranking, 1-based. + #[ts(type = "number")] + pub rank: u32, + /// The SS58 address, which is what a reader recognises and what the page + /// for it is addressed by. + pub address: String, + /// The raw account id, `0x` hex. + pub account: String, + /// The reward preimage behind it, when the observer recorded a block it was + /// paid for. Absent for an account whose blocks all predate this observer. + pub miner: Option, + /// A telemetry name for that miner, if one is held. + pub display: Option, + /// Where `display` came from. Carried for the same reason the leaderboard + /// carries it: a name here is an inference from which node reported a block + /// first, and a row that renders a guess identically to a settled name is + /// making a claim the observer cannot support. + pub attribution: AttributionSource, + /// Agreement behind the held name, 0.0–1.0. Meaningless when `attribution` + /// is `Preimage`. + pub confidence: f32, + /// How many votes are behind it. A fraction alone cannot tell one vote of + /// one from nine of nine, and those are not the same claim. + #[ts(type = "number")] + pub attribution_votes: u32, + /// How many blocks it was paid for. + #[ts(type = "number")] + pub blocks: u64, + /// Their sum, in the chain's smallest unit. + pub total: BigUintDec, +} + /// What an account has been paid for mining. #[derive(Debug, Clone, Serialize, Deserialize, TS)] #[ts(export, export_to = "RewardSummary.ts")] diff --git a/crates/blackbeard-entities/src/lib.rs b/crates/blackbeard-entities/src/lib.rs index 0504a34..76595d7 100644 --- a/crates/blackbeard-entities/src/lib.rs +++ b/crates/blackbeard-entities/src/lib.rs @@ -24,7 +24,7 @@ mod runtime; mod series; mod ws; -pub use account::{AccountDetail, AccountEvent, RewardSummary}; +pub use account::{AccountDetail, AccountEvent, AccountRow, RewardSummary}; pub use block::{BlockDetail, BlockObservation, RecentBlock}; pub use chain::{ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, Tracking}; pub use error::{ApiError, EntityError}; diff --git a/readme.md b/readme.md index 813e04e..18239c0 100644 --- a/readme.md +++ b/readme.md @@ -214,6 +214,36 @@ tokens, and the derivation joins them. Going the other way — address to preima — is a lookup among preimages already seen, not a calculation, because the Poseidon2 derivation runs one way only. +## Sections, so the addresses are findable + +Every route here was reachable only by already knowing an address: a block hash, +a preimage, an SS58 string, a `spec_version`. That is fine for a link somebody +sent you and useless for finding out the pages exist. A kind with nothing after +it is now the index of that kind — `/quantus/block`, `/quantus/account`, +`/quantus/runtime` — and a section nav under the chain chips names all of them. + +There is deliberately no `/quantus/miner`. The standings **are** the index of +miners, and a second page listing the same rows under a different address would +be two answers to one question; that path redirects there, as does anything else +that resolves to the front page but is not spelled like it. + +The three indexes are not the same page three times: + +- **Blocks** is the record behind the live ticker — the same rows from Postgres + rather than memory, paged by height. Its gaps come from `authored_at`, the + chain's own clock, *not* from `observed_at`: a gap fill writes a whole batch + within the same second, and the first cut of this page reported three + milliseconds between blocks on a chain targeting twelve seconds. +- **Accounts** ranks by tokens earned over the whole indexed record, which is a + different question from the standings' blocks-per-window and on this chain a + different answer — difficulty rose 346-fold inside mainnet's first day, so an + early block cost a three-hundredth of a current one and paid accordingly. It + shows the address beside any node name, because one node legitimately reports + for several payout addresses and the first version had two rows reading + `pearl-prover` with nothing to tell them apart. +- **Runtimes** is the only list with no other home at all: a block does not name + its runtime and the standings do not either. + ## Every view has an address Paths, not a hash: `/quantus/day`, `/quantus/block/13160`, diff --git a/web/src/App.tsx b/web/src/App.tsx index 58dace1..895d868 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -11,12 +11,16 @@ import { useCallback, useEffect, useMemo } from 'react' import { Link, useLocation, useNavigate } from 'react-router-dom' import { AccountPanel } from './components/AccountPanel' +import { AccountsIndex } from './components/AccountsIndex' import { BlockPanel } from './components/BlockPanel' +import { BlocksIndex } from './components/BlocksIndex' import { BlockTicker } from './components/BlockTicker' import { ChainSwitcher } from './components/ChainSwitcher' import { Leaderboard } from './components/Leaderboard' import { MinerPanel } from './components/MinerPanel' import { RuntimePanel } from './components/RuntimePanel' +import { RuntimesIndex } from './components/RuntimesIndex' +import { SectionNav } from './components/SectionNav' import { StatBar } from './components/StatBar' import { seconds, windowSpan } from './lib/format' import { href, parse, WINDOWS } from './lib/routes' @@ -49,6 +53,25 @@ export default function App() { } }, [route.chain, route.window, fallbackChain, navigate]) + // Normalise a path that resolves to the standings but is not spelled like + // them: `/quantus`, `/quantus/miner` (the standings *are* the miner index), + // and anything unrecognised, which `parse` degrades to the front page rather + // than a 404. Without this the address bar keeps saying something the page is + // not, and the section nav cannot mark where the reader is. + useEffect(() => { + if (!chain) return + const standings = + route.index === null && + route.block === null && + route.miner === null && + route.account === null && + route.runtime === null + const canonical = href({ chain, window: route.window }) + if (standings && location.pathname !== canonical) { + navigate(canonical, { replace: true }) + } + }, [chain, route, location.pathname, navigate]) + useWatch(chain, route.window) /** @@ -111,6 +134,8 @@ export default function App() { + + {info?.status === 'awaiting' && (
{info.display_name} has not started producing blocks yet. This page will @@ -164,33 +189,49 @@ export default function App() { for. */} {route.runtime !== null && chain && } - {!route.account && !route.miner && !route.block && route.runtime === null && ( -
-
-
-

The Standings

- - last {activeWindow.blocks.toLocaleString('en-US')} blocks ·{' '} - {windowSpan(activeWindow.blocks, interval)} - {pinned.length > 0 && ` · ${pinned.length} marked yours`} - -
- -
- -
-
-

Live Blocks

- - {state.summary?.block_interval_seconds - ? `${seconds(state.summary.block_interval_seconds)} apart` - : 'measuring'} - -
- -
-
+ {route.index === 'block' && chain && } + {route.index === 'account' && chain && ( + )} + {route.index === 'runtime' && chain && ( + + )} + + {!route.account && + !route.miner && + !route.block && + route.runtime === null && + route.index === null && ( +
+
+
+

The Standings

+ + last {activeWindow.blocks.toLocaleString('en-US')} blocks ·{' '} + {windowSpan(activeWindow.blocks, interval)} + {pinned.length > 0 && ` · ${pinned.length} marked yours`} + +
+ +
+ +
+
+

Live Blocks

+ + {state.summary?.block_interval_seconds + ? `${seconds(state.summary.block_interval_seconds)} apart` + : 'measuring'} + +
+ +
+
+ )}