From 06f3ece22824c2fbb803b66cddb7b2c13e69e041 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Mon, 14 Sep 2026 08:59:55 +0300 Subject: [PATCH] feat: account flows, and a distribution table for mining rewards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported concern: one pool's balance is disproportionately large, and it is not clear whether rewards are reaching miners. This makes the question answerable from the chain rather than from impressions. Measured on mainnet while building it, blocks 1–42,274: account mined received sent recipients QUANPOOL 10,171 5,207 0 0 qzomrwjT… 0 11,382 4,071 90 qzo1Pr3X… 0 3,875 7,528 986 qzo4QjZz… 0 1,257 2,448 742 qznbesUc… 0 1,600 0 0 Direction comes from each transfer event's own `from` and `to` — `Wormhole:: NativeTransferred` and `Balances::Transfer` both carry them — so every figure is something the chain recorded. **A nonce cannot answer this, and reaching for one is the obvious mistake.** Wormhole transfers dispatch unsigned: `verify_private_batch` and `verify_public_batch` have no signer at all. An address here can move a fortune with a nonce of zero, and I nearly reported one as proof that nothing had ever been sent. The presentation reports flows and does not judge them, because the data cannot settle what they mean. Two facts in the table above are why: several large accounts send nothing — ordinary for a solo miner keeping what it earned — and the accounts distributing to hundreds of recipients have never mined a block, so payout wallets here are funded from somewhere other than mining addresses. There is also no such thing as a "pool" on this chain: the word appears only in a name its operator reports over telemetry, which `MinerFlow::attribution` carries so a reader can weigh it. What earns the feature its place is comparison. One miner's recipient count says little; beside its neighbours' it says a great deal, and the reader draws the conclusion rather than the site. Refs #17 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5 --- ...4f79ef31cf33677396ddd6f2ecc356ff7b045.json | 59 ++++++++ ...79234db0bcb647411773c6416bb072267f35d.json | 53 +++++++ crates/blackbeard-api/src/routes.rs | 142 +++++++++++++++++- crates/blackbeard-data/src/store.rs | 133 ++++++++++++++++ crates/blackbeard-entities/src/account.rs | 6 + crates/blackbeard-entities/src/chain.rs | 87 ++++++++++- crates/blackbeard-entities/src/lib.rs | 4 +- web/src/api/generated/AccountDetail.ts | 11 +- web/src/api/generated/AccountFlows.ts | 45 ++++++ web/src/api/generated/Distribution.ts | 31 ++++ web/src/api/generated/MinerFlow.ts | 48 ++++++ 11 files changed, 608 insertions(+), 11 deletions(-) create mode 100644 .sqlx/query-717abd3ad62607561855d8fbe6c4f79ef31cf33677396ddd6f2ecc356ff7b045.json create mode 100644 .sqlx/query-fbede02f645d92cce9e02673db479234db0bcb647411773c6416bb072267f35d.json create mode 100644 web/src/api/generated/AccountFlows.ts create mode 100644 web/src/api/generated/Distribution.ts create mode 100644 web/src/api/generated/MinerFlow.ts diff --git a/.sqlx/query-717abd3ad62607561855d8fbe6c4f79ef31cf33677396ddd6f2ecc356ff7b045.json b/.sqlx/query-717abd3ad62607561855d8fbe6c4f79ef31cf33677396ddd6f2ecc356ff7b045.json new file mode 100644 index 0000000..176db76 --- /dev/null +++ b/.sqlx/query-717abd3ad62607561855d8fbe6c4f79ef31cf33677396ddd6f2ecc356ff7b045.json @@ -0,0 +1,59 @@ +{ + "db_name": "PostgreSQL", + "query": "\n with moves as (\n select fields->>'from' as src,\n fields->>'to' as dst,\n (fields->>'amount')::numeric as amount\n from chain_event\n where chain = $1\n and accounts && $2::text[]\n and ( (pallet = 'Wormhole' and variant = 'NativeTransferred')\n or (pallet = 'Balances' and variant = 'Transfer'))\n ),\n wanted as (select unnest($2::text[]) as account)\n select w.account as \"account!\",\n coalesce((select sum(amount) from moves where src = w.account), 0) as \"sent!\",\n coalesce((select sum(amount) from moves where dst = w.account), 0) as \"received!\",\n (select count(*) from moves where src = w.account) as \"sends!\",\n (select count(*) from moves where dst = w.account) as \"receipts!\",\n (select count(distinct dst) from moves where src = w.account) as \"recipients!\",\n (select count(distinct src) from moves where dst = w.account) as \"senders!\"\n from wanted w\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "account!", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "sent!", + "type_info": "Numeric" + }, + { + "ordinal": 2, + "name": "received!", + "type_info": "Numeric" + }, + { + "ordinal": 3, + "name": "sends!", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "receipts!", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "recipients!", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "senders!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "TextArray" + ] + }, + "nullable": [ + null, + null, + null, + null, + null, + null, + null + ] + }, + "hash": "717abd3ad62607561855d8fbe6c4f79ef31cf33677396ddd6f2ecc356ff7b045" +} diff --git a/.sqlx/query-fbede02f645d92cce9e02673db479234db0bcb647411773c6416bb072267f35d.json b/.sqlx/query-fbede02f645d92cce9e02673db479234db0bcb647411773c6416bb072267f35d.json new file mode 100644 index 0000000..d72b69e --- /dev/null +++ b/.sqlx/query-fbede02f645d92cce9e02673db479234db0bcb647411773c6416bb072267f35d.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "\n select\n coalesce(sum((fields->>'amount')::numeric)\n filter (where fields->>'from' = $2), 0) as \"sent!\",\n coalesce(sum((fields->>'amount')::numeric)\n filter (where fields->>'to' = $2), 0) as \"received!\",\n count(*) filter (where fields->>'from' = $2) as \"sends!\",\n count(*) filter (where fields->>'to' = $2) as \"receipts!\",\n count(distinct fields->>'to')\n filter (where fields->>'from' = $2) as \"recipients!\",\n count(distinct fields->>'from')\n filter (where fields->>'to' = $2) as \"senders!\"\n from chain_event\n where chain = $1\n and accounts @> array[$2]\n and ( (pallet = 'Wormhole' and variant = 'NativeTransferred')\n or (pallet = 'Balances' and variant = 'Transfer'))\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "sent!", + "type_info": "Numeric" + }, + { + "ordinal": 1, + "name": "received!", + "type_info": "Numeric" + }, + { + "ordinal": 2, + "name": "sends!", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "receipts!", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "recipients!", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "senders!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null, + null, + null, + null, + null, + null + ] + }, + "hash": "fbede02f645d92cce9e02673db479234db0bcb647411773c6416bb072267f35d" +} diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index 8a45b1c..e68f594 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -16,13 +16,14 @@ use axum::response::{IntoResponse, Response}; use axum::routing::get; use axum::{Json, Router}; use blackbeard_entities::{ - AccountDetail, AccountEvent, AccountRoleEntry, AccountRow, ActivitySource, ApiError, - BigUintDec, BlockDetail, CallIndex, CallSummary, ChainInfo, ChainRoles, ChainSeries, - ChainState, ChainSummary, DailyActivity, EventSummary, GenesisDetail, LeaderboardRow, - LockCounts, MinerDetail, MinerId, MinerSeriesPoint, NamedAccount, NetworkSummary, NodeActivity, - NodeIndex, NodeInfo, NodeRow, PendingTransfer, RecentBlock, ReversibleState, RewardSummary, - RoleSource, RuntimeConstant, RuntimeDetail, RuntimeField, RuntimePallet, - RuntimeSignedExtension, RuntimeStorage, RuntimeSummary, RuntimeVariant, StateEntry, Window, + AccountDetail, AccountEvent, AccountFlows, AccountRoleEntry, AccountRow, ActivitySource, + ApiError, BigUintDec, BlockDetail, CallIndex, CallSummary, ChainInfo, ChainRoles, ChainSeries, + ChainState, ChainSummary, DailyActivity, Distribution, EventSummary, GenesisDetail, + LeaderboardRow, LockCounts, MinerDetail, MinerFlow, MinerId, MinerSeriesPoint, NamedAccount, + NetworkSummary, NodeActivity, NodeIndex, NodeInfo, NodeRow, PendingTransfer, RecentBlock, + ReversibleState, RewardSummary, RoleSource, RuntimeConstant, RuntimeDetail, RuntimeField, + RuntimePallet, RuntimeSignedExtension, RuntimeStorage, RuntimeSummary, RuntimeVariant, + StateEntry, Window, }; use serde::{Deserialize, Serialize}; use tower_http::compression::CompressionLayer; @@ -56,6 +57,7 @@ pub fn router(state: AppState, allowed_origins: &[String]) -> Router { .route("/v1/chains/{chain}/blocks/{block}", get(block)) .route("/v1/chains/{chain}/miners/{miner}", get(miner)) .route("/v1/chains/{chain}/network", get(network)) + .route("/v1/chains/{chain}/distribution", get(distribution)) .route("/v1/chains/{chain}/nodes", get(nodes)) .route("/v1/chains/{chain}/nodes/{peer}", get(node)) .route("/v1/chains/{chain}/accounts", get(accounts)) @@ -784,6 +786,114 @@ async fn miner( })) } +/// Miners the distribution table covers. +/// +/// Enough that the long tail is visible beside the head: a single row reading +/// "78% of blocks, no recipients" means nothing without the rows under it +/// showing what everyone else did with theirs. +const DISTRIBUTION_ROWS: usize = 25; + +/// `GET /v1/chains/{chain}/distribution` +/// +/// Who won the blocks, and what became of the rewards. +/// +/// **Reports flows; does not judge them.** Direction comes from each transfer +/// event's own `from` and `to`, so every figure is something the chain +/// recorded. What the figures *mean* is a question the data cannot settle: an +/// operator may hold several addresses, may settle off-chain, or may simply be +/// a solo miner keeping what it earned — the ordinary case, and unremarkable. +/// The reason to assemble this at all is comparison: one miner's recipient +/// count says little alone and a great deal beside its neighbours'. +async fn distribution( + State(state): State, + Path(chain): Path, + Query(query): Query, +) -> Result, Failure> { + let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?; + let id = runtime.id(); + let window = query.window()?; + + let rows: Vec = runtime + .leaderboard(window, state.leaderboard_max_age) + .into_iter() + .take(DISTRIBUTION_ROWS) + .collect(); + + // The account each miner is paid at, derived from its preimage by the same + // function the chain uses to decide who to pay — so the join is the chain's + // own rather than a guess of ours. + let accounts: Vec = rows + .iter() + .filter_map(|r| { + r.address + .as_deref() + .and_then(blackbeard_core::wormhole::account_id) + }) + .collect(); + + let flows = state + .store + .flows_for(&id, &accounts) + .await + .map_err(database_unavailable)?; + let scan = state + .store + .event_scan(&id) + .await + .map_err(database_unavailable)?; + let mut miners = Vec::with_capacity(rows.len()); + for row in rows { + let account = row + .address + .as_deref() + .and_then(blackbeard_core::wormhole::account_id); + // Keyed by the account, not the preimage: rewards are recorded against + // whoever the chain paid. + let mined = match &account { + Some(account) => state + .store + .account_rewards(&id, account) + .await + .ok() + .map(|r| BigUintDec(r.total)), + None => None, + }; + let balance = match &account { + Some(account) => read_balance(&runtime, account).await.0, + None => None, + }; + miners.push(MinerFlow { + display: row.display.clone(), + attribution: row.attribution, + blocks: row.blocks, + share: row.share, + mined, + balance, + flows: account + .as_ref() + .and_then(|a| flows.get(a)) + .map(|f| AccountFlows { + sent: BigUintDec(f.sent.clone()), + received: BigUintDec(f.received.clone()), + sends: f.sends, + receipts: f.receipts, + recipients: f.recipients, + senders: f.senders, + }), + address: row.address, + miner: row.miner, + }); + } + + Ok(Json(Distribution { + chain: id, + window_blocks: window.blocks(), + miners, + indexed_from: scan.map(|s| s.0), + indexed_to: scan.map(|s| s.1), + })) +} + /// Days of activity the network page reports. const NETWORK_DAYS: i32 = 30; @@ -2385,8 +2495,26 @@ async fn account( } }); + // What it has received and sent on. Facts, not a verdict — an account that + // has sent nothing is the ordinary case for a miner keeping what it earned, + // and the figure earns its place by being comparable rather than damning. + let flows = state + .store + .account_flows(&id, &account) + .await + .ok() + .map(|f| AccountFlows { + sent: BigUintDec(f.sent), + received: BigUintDec(f.received), + sends: f.sends, + receipts: f.receipts, + recipients: f.recipients, + senders: f.senders, + }); + Ok(Json(AccountDetail { chain: id, + flows, // Re-encoded rather than echoed: the answer is about the account, and // returning the caller's spelling of it would let two spellings of one // address look like two accounts. diff --git a/crates/blackbeard-data/src/store.rs b/crates/blackbeard-data/src/store.rs index 68740e4..6e7b092 100644 --- a/crates/blackbeard-data/src/store.rs +++ b/crates/blackbeard-data/src/store.rs @@ -60,6 +60,24 @@ pub struct StoreConfig { pub max_connections: u32, } +/// What an account has received and sent, over the indexed range. +#[derive(Debug, Clone, PartialEq)] +pub struct AccountFlows { + /// Total sent, smallest unit, as a decimal string. + pub sent: String, + /// Total received. + pub received: String, + /// Transfers out. + pub sends: u64, + /// Transfers in. + pub receipts: u64, + /// Distinct addresses it has sent to. The number that separates an account + /// distributing to many from one moving funds to itself. + pub recipients: u64, + /// Distinct addresses it has received from. + pub senders: u64, +} + /// One day of a chain's activity. /// /// Every count is over the chain's own clock and over whatever this observer @@ -1954,6 +1972,121 @@ impl Store { })) } + /// What one account has received and what it has sent on. + /// + /// Summed from the transfer events themselves — `Wormhole::NativeTransferred` + /// and `Balances::Transfer` both carry `from`, `to` and `amount`, so + /// direction is a fact in the payload rather than something inferred from + /// an account appearing nearby. + /// + /// **A nonce cannot answer this question on this chain.** Wormhole + /// transfers are dispatched unsigned — `verify_private_batch` and + /// `verify_public_batch` have no signer — so an address can move a great + /// deal while its nonce stays at zero. Reading a zero nonce as "has never + /// sent anything" would be badly wrong, and it is the obvious mistake to + /// make here. + /// + /// Scoped to the indexed range like everything else derived from our own + /// tables, and the caller has to say so. + pub async fn account_flows( + &self, + chain: &ChainId, + account: &str, + ) -> Result { + let row = sqlx::query!( + r#" + select + coalesce(sum((fields->>'amount')::numeric) + filter (where fields->>'from' = $2), 0) as "sent!", + coalesce(sum((fields->>'amount')::numeric) + filter (where fields->>'to' = $2), 0) as "received!", + count(*) filter (where fields->>'from' = $2) as "sends!", + count(*) filter (where fields->>'to' = $2) as "receipts!", + count(distinct fields->>'to') + filter (where fields->>'from' = $2) as "recipients!", + count(distinct fields->>'from') + filter (where fields->>'to' = $2) as "senders!" + from chain_event + where chain = $1 + and accounts @> array[$2] + and ( (pallet = 'Wormhole' and variant = 'NativeTransferred') + or (pallet = 'Balances' and variant = 'Transfer')) + "#, + chain.as_str(), + account, + ) + .fetch_one(&self.pool) + .await?; + + Ok(AccountFlows { + sent: row.sent.to_string(), + received: row.received.to_string(), + sends: row.sends as u64, + receipts: row.receipts as u64, + recipients: row.recipients as u64, + senders: row.senders as u64, + }) + } + + /// Flows for many accounts at once. + /// + /// One query rather than one per row: a distribution table asks the same + /// question of twenty accounts, and twenty round trips to answer it would + /// make the page the slowest on the site. + pub async fn flows_for( + &self, + chain: &ChainId, + accounts: &[String], + ) -> Result, DataError> { + if accounts.is_empty() { + return Ok(std::collections::HashMap::new()); + } + let rows = sqlx::query!( + r#" + with moves as ( + select fields->>'from' as src, + fields->>'to' as dst, + (fields->>'amount')::numeric as amount + from chain_event + where chain = $1 + and accounts && $2::text[] + and ( (pallet = 'Wormhole' and variant = 'NativeTransferred') + or (pallet = 'Balances' and variant = 'Transfer')) + ), + wanted as (select unnest($2::text[]) as account) + select w.account as "account!", + coalesce((select sum(amount) from moves where src = w.account), 0) as "sent!", + coalesce((select sum(amount) from moves where dst = w.account), 0) as "received!", + (select count(*) from moves where src = w.account) as "sends!", + (select count(*) from moves where dst = w.account) as "receipts!", + (select count(distinct dst) from moves where src = w.account) as "recipients!", + (select count(distinct src) from moves where dst = w.account) as "senders!" + from wanted w + "#, + chain.as_str(), + accounts, + ) + .fetch_all(&self.pool) + .await?; + + Ok(rows + .into_iter() + .map(|r| { + ( + r.account, + AccountFlows { + sent: r.sent.to_string(), + received: r.received.to_string(), + sends: r.sends as u64, + receipts: r.receipts as u64, + recipients: r.recipients as u64, + senders: r.senders as u64, + }, + ) + }) + .collect()) + } + /// One day of the chain's activity, from this observer's index. /// /// Bucketed by the *chain's* clock — `chain_extrinsic.at` and `block.authored_at` diff --git a/crates/blackbeard-entities/src/account.rs b/crates/blackbeard-entities/src/account.rs index 920d877..f380d60 100644 --- a/crates/blackbeard-entities/src/account.rs +++ b/crates/blackbeard-entities/src/account.rs @@ -69,6 +69,12 @@ pub struct AccountDetail { /// The highest block whose events have been read. #[ts(type = "number")] pub indexed_to: Option, + /// What this account has received and sent on, over the indexed range. + /// + /// Facts rather than a verdict — see [`crate::AccountFlows`]. An account + /// that has sent nothing is the ordinary case for a miner keeping what it + /// earned; the figure is worth showing because it is worth *comparing*. + pub flows: Option, } /// One row of the accounts index: an account and what it has earned. diff --git a/crates/blackbeard-entities/src/chain.rs b/crates/blackbeard-entities/src/chain.rs index ed1cfe7..6563475 100644 --- a/crates/blackbeard-entities/src/chain.rs +++ b/crates/blackbeard-entities/src/chain.rs @@ -4,7 +4,7 @@ use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use ts_rs::TS; -use crate::BigUintDec; +use crate::{AttributionSource, BigUintDec, MinerId}; /// Short, stable, URL-safe name for a chain (`planck`, `quantus`). /// @@ -327,3 +327,88 @@ pub struct DailyActivity { #[ts(type = "number")] pub new_accounts: u64, } + +/// What one account has received and sent on, over the indexed range. +/// +/// **Facts, not a verdict.** Direction comes from the transfer events' +/// own `from` and `to` fields, so these are what the chain recorded. What they +/// cannot tell you is what the numbers *mean*: an operator may hold several +/// addresses, may settle off-chain, or may simply be a solo miner keeping what +/// it earned — which is the ordinary case and not remarkable in the slightest. +/// +/// A nonce cannot substitute for any of this. Wormhole transfers dispatch +/// unsigned, so an address on this chain can move a fortune with a nonce of +/// zero. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "AccountFlows.ts")] +pub struct AccountFlows { + /// Total sent, smallest unit. + pub sent: BigUintDec, + /// Total received. + pub received: BigUintDec, + /// Transfers out. + #[ts(type = "number")] + pub sends: u64, + /// Transfers in. + #[ts(type = "number")] + pub receipts: u64, + /// Distinct addresses it has sent to. + /// + /// The number that separates an account distributing to many from one that + /// has moved nothing — and the one figure here a reader can compare across + /// accounts without knowing anything about any of them. + #[ts(type = "number")] + pub recipients: u64, + /// Distinct addresses it has received from. + #[ts(type = "number")] + pub senders: u64, +} + +/// One miner's share of the chain's mining, and what it has done with it. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "MinerFlow.ts")] +pub struct MinerFlow { + /// The reward preimage. + pub miner: MinerId, + /// The address the chain pays it at. + pub address: Option, + /// What to call it. **Self-reported over telemetry, or the address** — the + /// chain asserts no names. A name containing "pool" is a claim its operator + /// made about itself and nothing more. + pub display: String, + /// How that name was arrived at, so a reader can weigh it. + pub attribution: AttributionSource, + /// Blocks won in the window. + #[ts(type = "number")] + pub blocks: u32, + /// Its share of the window's blocks, 0.0–1.0. + pub share: f64, + /// What the chain has paid it in rewards, over the indexed range. + pub mined: Option, + /// What it holds now, read from chain state. + pub balance: Option, + /// Flows in and out. + pub flows: Option, +} + +/// How the chain's mining is shared, and where the rewards went. +/// +/// Assembled so the comparison is possible at all: one miner's recipient count +/// means little alone, and means a great deal beside its neighbours'. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "Distribution.ts")] +pub struct Distribution { + /// Which chain. + pub chain: ChainId, + /// The window the shares were computed over, in blocks. + #[ts(type = "number")] + pub window_blocks: u32, + /// Miners, most blocks first. + pub miners: Vec, + /// Lowest block whose events this observer has read. + #[ts(type = "number")] + pub indexed_from: Option, + /// Highest. + #[ts(type = "number")] + pub indexed_to: Option, +} diff --git a/crates/blackbeard-entities/src/lib.rs b/crates/blackbeard-entities/src/lib.rs index 35ed5bc..9548f56 100644 --- a/crates/blackbeard-entities/src/lib.rs +++ b/crates/blackbeard-entities/src/lib.rs @@ -32,8 +32,8 @@ pub use account::{AccountDetail, AccountEvent, AccountRow, ActivitySource, Rewar pub use block::{BlockDetail, BlockEvent, BlockExtrinsic, BlockObservation, RecentBlock}; pub use call::{CallIndex, CallSummary, EventSummary}; pub use chain::{ - ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, DailyActivity, LockCounts, - NetworkSummary, Tracking, + AccountFlows, ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, DailyActivity, + Distribution, LockCounts, MinerFlow, NetworkSummary, Tracking, }; pub use error::{ApiError, EntityError}; pub use miner::{ diff --git a/web/src/api/generated/AccountDetail.ts b/web/src/api/generated/AccountDetail.ts index 150247b..d210fe1 100644 --- a/web/src/api/generated/AccountDetail.ts +++ b/web/src/api/generated/AccountDetail.ts @@ -1,5 +1,6 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. import type { AccountEvent } from "./AccountEvent"; +import type { AccountFlows } from "./AccountFlows"; import type { AccountRoleEntry } from "./AccountRoleEntry"; import type { BigUintDec } from "./BigUintDec"; import type { ChainId } from "./ChainId"; @@ -80,4 +81,12 @@ indexed_from: number, /** * The highest block whose events have been read. */ -indexed_to: number, }; +indexed_to: number, +/** + * What this account has received and sent on, over the indexed range. + * + * Facts rather than a verdict — see [`crate::AccountFlows`]. An account + * that has sent nothing is the ordinary case for a miner keeping what it + * earned; the figure is worth showing because it is worth *comparing*. + */ +flows: AccountFlows | null, }; diff --git a/web/src/api/generated/AccountFlows.ts b/web/src/api/generated/AccountFlows.ts new file mode 100644 index 0000000..c242e9f --- /dev/null +++ b/web/src/api/generated/AccountFlows.ts @@ -0,0 +1,45 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { BigUintDec } from "./BigUintDec"; + +/** + * What one account has received and sent on, over the indexed range. + * + * **Facts, not a verdict.** Direction comes from the transfer events' + * own `from` and `to` fields, so these are what the chain recorded. What they + * cannot tell you is what the numbers *mean*: an operator may hold several + * addresses, may settle off-chain, or may simply be a solo miner keeping what + * it earned — which is the ordinary case and not remarkable in the slightest. + * + * A nonce cannot substitute for any of this. Wormhole transfers dispatch + * unsigned, so an address on this chain can move a fortune with a nonce of + * zero. + */ +export type AccountFlows = { +/** + * Total sent, smallest unit. + */ +sent: BigUintDec, +/** + * Total received. + */ +received: BigUintDec, +/** + * Transfers out. + */ +sends: number, +/** + * Transfers in. + */ +receipts: number, +/** + * Distinct addresses it has sent to. + * + * The number that separates an account distributing to many from one that + * has moved nothing — and the one figure here a reader can compare across + * accounts without knowing anything about any of them. + */ +recipients: number, +/** + * Distinct addresses it has received from. + */ +senders: number, }; diff --git a/web/src/api/generated/Distribution.ts b/web/src/api/generated/Distribution.ts new file mode 100644 index 0000000..41307b0 --- /dev/null +++ b/web/src/api/generated/Distribution.ts @@ -0,0 +1,31 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChainId } from "./ChainId"; +import type { MinerFlow } from "./MinerFlow"; + +/** + * How the chain's mining is shared, and where the rewards went. + * + * Assembled so the comparison is possible at all: one miner's recipient count + * means little alone, and means a great deal beside its neighbours'. + */ +export type Distribution = { +/** + * Which chain. + */ +chain: ChainId, +/** + * The window the shares were computed over, in blocks. + */ +window_blocks: number, +/** + * Miners, most blocks first. + */ +miners: Array, +/** + * Lowest block whose events this observer has read. + */ +indexed_from: number, +/** + * Highest. + */ +indexed_to: number, }; diff --git a/web/src/api/generated/MinerFlow.ts b/web/src/api/generated/MinerFlow.ts new file mode 100644 index 0000000..3e78884 --- /dev/null +++ b/web/src/api/generated/MinerFlow.ts @@ -0,0 +1,48 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { AccountFlows } from "./AccountFlows"; +import type { AttributionSource } from "./AttributionSource"; +import type { BigUintDec } from "./BigUintDec"; +import type { MinerId } from "./MinerId"; + +/** + * One miner's share of the chain's mining, and what it has done with it. + */ +export type MinerFlow = { +/** + * The reward preimage. + */ +miner: MinerId, +/** + * The address the chain pays it at. + */ +address: string | null, +/** + * What to call it. **Self-reported over telemetry, or the address** — the + * chain asserts no names. A name containing "pool" is a claim its operator + * made about itself and nothing more. + */ +display: string, +/** + * How that name was arrived at, so a reader can weigh it. + */ +attribution: AttributionSource, +/** + * Blocks won in the window. + */ +blocks: number, +/** + * Its share of the window's blocks, 0.0–1.0. + */ +share: number, +/** + * What the chain has paid it in rewards, over the indexed range. + */ +mined: BigUintDec | null, +/** + * What it holds now, read from chain state. + */ +balance: BigUintDec | null, +/** + * Flows in and out. + */ +flows: AccountFlows | null, };