From e3c95d4945351974fa10580070cd0e6d04576763 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Mon, 14 Sep 2026 09:32:27 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20a=20wormhole=20page=20=E2=80=94=20what?= =?UTF-8?q?=20enters,=20what=20leaves,=20what=20is=20still=20inside?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The observer had nothing to say about this chain's central feature, which is why the distribution table's outflow column could ship reading zero for every miner and look like evidence. A transfer inserts a commitment as a note; spending one proves a nullifier, which the runtime records so it cannot be spent twice. That makes the pool countable even though its movements are not: ZkTree::LeafCount 82,691 notes created Wormhole::UsedNullifiers 47,796 notes spent outstanding 34,895 unspent — still inside Outstanding is the headline. It is the part of the chain's activity that has not moved, and every one of those notes will name a recipient and an amount and not a sender when it does. The routes table separates transfers by the extrinsic that produced them, because a hidden sender means two different things. In block initialisation it is the chain paying a miner and the "sender" is the runtime; inside `verify_public_batch` or `verify_private_batch` it is somebody spending a note and the origin genuinely is not recorded. Collapsing those would make mining rewards look like anonymous payments — 23,876 of them. Notes per day come from the `leaf_index` each transfer carries rather than from `ZkTree::LeafInserted`, which is in `SKIPPED_EVENTS` because it fires every block and names no account. Same fact, cheaper road. `SegmentsDenied` and `ExitMintFailed` have never fired. Shown anyway, because a proof rejection nobody has ever seen is a finding rather than a gap — though it is a weaker statement than it looks, and the note says so: never refused is not the same as never attempted. Closes #18 Co-Authored-By: Claude Opus 5 --- ...b1946d73ea0908ada9212941a1e456dcb7567.json | 47 ++++ ...df795487fc67e77a796eb387f265532e9a23c.json | 42 ++++ CLAUDE.md | 32 +++ crates/blackbeard-api/src/routes.rs | 118 +++++++++- crates/blackbeard-data/src/store.rs | 138 +++++++++++ crates/blackbeard-entities/src/chain.rs | 85 +++++++ crates/blackbeard-entities/src/lib.rs | 3 +- web/src/App.tsx | 8 + web/src/api/generated/DailyWormhole.ts | 23 ++ web/src/api/generated/TransferRoute.ts | 28 +++ web/src/api/generated/WormholeSummary.ts | 71 ++++++ web/src/api/rest.ts | 6 + web/src/components/WormholePanel.tsx | 217 ++++++++++++++++++ web/src/lib/routes.ts | 15 +- 14 files changed, 825 insertions(+), 8 deletions(-) create mode 100644 .sqlx/query-6e5b0988898e0f185fa26915206b1946d73ea0908ada9212941a1e456dcb7567.json create mode 100644 .sqlx/query-9c577458e595877d421482f1835df795487fc67e77a796eb387f265532e9a23c.json create mode 100644 web/src/api/generated/DailyWormhole.ts create mode 100644 web/src/api/generated/TransferRoute.ts create mode 100644 web/src/api/generated/WormholeSummary.ts create mode 100644 web/src/components/WormholePanel.tsx diff --git a/.sqlx/query-6e5b0988898e0f185fa26915206b1946d73ea0908ada9212941a1e456dcb7567.json b/.sqlx/query-6e5b0988898e0f185fa26915206b1946d73ea0908ada9212941a1e456dcb7567.json new file mode 100644 index 0000000..1751f32 --- /dev/null +++ b/.sqlx/query-6e5b0988898e0f185fa26915206b1946d73ea0908ada9212941a1e456dcb7567.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "\n select\n coalesce(x.pallet || '::' || x.call, '') as \"produced_by!\",\n (e.fields->>'from' = $2) as \"sender_hidden!\",\n count(*) as \"transfers!\",\n count(distinct e.fields->>'to') as \"recipients!\",\n coalesce(sum((e.fields->>'amount')::numeric), 0) as \"amount!\"\n from chain_event e\n left join chain_extrinsic x\n on x.chain = e.chain\n and x.height = e.height\n and x.extrinsic_index = e.extrinsic_index\n where e.chain = $1\n and ( (e.pallet = 'Wormhole' and e.variant = 'NativeTransferred')\n or (e.pallet = 'Balances' and e.variant = 'Transfer'))\n group by 1, 2\n order by count(*) desc\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "produced_by!", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "sender_hidden!", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "transfers!", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "recipients!", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "amount!", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + null, + null, + null, + null, + null + ] + }, + "hash": "6e5b0988898e0f185fa26915206b1946d73ea0908ada9212941a1e456dcb7567" +} diff --git a/.sqlx/query-9c577458e595877d421482f1835df795487fc67e77a796eb387f265532e9a23c.json b/.sqlx/query-9c577458e595877d421482f1835df795487fc67e77a796eb387f265532e9a23c.json new file mode 100644 index 0000000..491bd18 --- /dev/null +++ b/.sqlx/query-9c577458e595877d421482f1835df795487fc67e77a796eb387f265532e9a23c.json @@ -0,0 +1,42 @@ +{ + "db_name": "PostgreSQL", + "query": "\n with span as (\n select generate_series(\n date_trunc('day', now()) - make_interval(days => $3::int - 1),\n date_trunc('day', now()),\n interval '1 day'\n ) as day\n ),\n moves as (\n select date_trunc('day', at) as day,\n fields->>'from' = $2 as hidden,\n (fields->>'amount')::numeric as amount\n from chain_event\n where chain = $1 and at is not null\n and pallet = 'Wormhole' and variant = 'NativeTransferred'\n )\n select span.day as \"day!\",\n coalesce((select count(*) from moves m where m.day = span.day), 0) as \"notes!\",\n coalesce((select sum(amount) from moves m where m.day = span.day), 0) as \"amount!\",\n coalesce((select count(*) from moves m\n where m.day = span.day and m.hidden), 0) as \"hidden!\"\n from span\n order by span.day asc\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "day!", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "notes!", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "amount!", + "type_info": "Numeric" + }, + { + "ordinal": 3, + "name": "hidden!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Int4" + ] + }, + "nullable": [ + null, + null, + null, + null + ] + }, + "hash": "9c577458e595877d421482f1835df795487fc67e77a796eb387f265532e9a23c" +} diff --git a/CLAUDE.md b/CLAUDE.md index ea2b5e9..cc47cf2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -608,6 +608,38 @@ from genesis they agree; on one still being indexed backwards a miner appears to hold far more than it ever mined. The note under the table says so, because the discrepancy looks exactly like the thing the table is for. +## The wormhole page is where the chain's privacy is accounted for + +A transfer inserts a commitment as a **note** in the zk tree; spending one proves +a **nullifier**, which the runtime records in `Wormhole::UsedNullifiers` so it +cannot be spent twice. So the pool is countable even though its movements are +not: + + ZkTree::LeafCount 82,644 notes created + Wormhole::UsedNullifiers 47,789 notes spent (map enumerated) + outstanding 34,855 unspent — still inside + +`Wormhole::TransferCount` and `ZkTree::UnprocessedLeaves` read as absent, so they +come back as their declared defaults rather than as unknown — see the storage +paragraph above. + +**Notes per day come from the transfers, not from `ZkTree::LeafInserted`.** That +event is in `SKIPPED_EVENTS` — it fires every block and names no account, so it +is not indexed and should not be. Every `NativeTransferred` carries a +`leaf_index` anyway, which is the same fact by a cheaper road. + +**Routes are separated by the extrinsic that produced them**, because a hidden +sender means two different things. In block initialisation it is the chain +paying a miner, and the "sender" is the runtime. Inside `verify_public_batch` or +`verify_private_batch` it is a person spending a note, and the origin genuinely +is not recorded. Collapsing the two would make mining rewards look like +anonymous payments. + +This page exists because the distribution table shipped an outflow column that +read zero for every miner and looked like evidence. The site now has somewhere +that says how much of the chain is unobservable and by which route, so that zero +can be read correctly. + ## The network page mixes two kinds of certainty, and says which `/:chain/network` puts chain state and this observer's index on one page, and diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index e68f594..ffe0d96 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -18,12 +18,12 @@ use axum::{Json, Router}; use blackbeard_entities::{ 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, + ChainState, ChainSummary, DailyActivity, DailyWormhole, 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, TransferRoute, Window, WormholeSummary, }; use serde::{Deserialize, Serialize}; use tower_http::compression::CompressionLayer; @@ -58,6 +58,7 @@ pub fn router(state: AppState, allowed_origins: &[String]) -> Router { .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}/wormhole", get(wormhole)) .route("/v1/chains/{chain}/nodes", get(nodes)) .route("/v1/chains/{chain}/nodes/{peer}", get(node)) .route("/v1/chains/{chain}/accounts", get(accounts)) @@ -786,6 +787,111 @@ async fn miner( })) } +/// Days of wormhole activity the route reports. +const WORMHOLE_DAYS: i32 = 30; + +/// `GET /v1/chains/{chain}/wormhole` +/// +/// What enters the wormhole, what leaves, and what is still inside. +/// +/// The chain's transfers are shielded: a transfer inserts a note, spending one +/// proves a nullifier, and the runtime records the nullifier so it cannot be +/// spent twice. Counting both gives the outstanding set — the part of this +/// chain's value that has not moved and whose movement, when it comes, will not +/// name a sender. +async fn wormhole( + State(state): State, + Path(chain): Path, +) -> Result, Failure> { + let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?; + let id = runtime.id(); + let parsed = runtime.read().current_runtime(); + + let routes = state + .store + .wormhole_routes(&id) + .await + .map_err(database_unavailable)? + .into_iter() + .map(|r| TransferRoute { + produced_by: r.produced_by, + sender_hidden: r.sender_hidden, + transfers: r.transfers, + recipients: r.recipients, + amount: BigUintDec(r.amount), + }) + .collect(); + let days = state + .store + .wormhole_by_day(&id, WORMHOLE_DAYS) + .await + .map_err(database_unavailable)? + .into_iter() + .map(|d| DailyWormhole { + day: d.day, + notes: d.notes, + hidden: d.hidden, + amount: BigUintDec(d.amount), + }) + .collect(); + let scan = state + .store + .event_scan(&id) + .await + .map_err(database_unavailable)?; + + // Counts of the kinds that only matter as "has this ever happened". A zero + // is a finding here rather than a gap: a proof rejection nobody has ever + // seen says something about the chain that its absence would not. + let usage: std::collections::HashMap = state + .store + .event_usage(&id) + .await + .map(|rows| { + rows.into_iter() + .filter(|u| u.pallet == "Wormhole") + .map(|u| (u.variant, u.emitted)) + .collect() + }) + .unwrap_or_default(); + let fired = |variant: &str| usage.get(variant).copied().unwrap_or(0); + + let (notes_created, tree_depth, tree_root, notes_spent, notes_spent_complete) = match &parsed { + Some(parsed) => { + let notes_created = + as_count(plain_value(&runtime, parsed, "ZkTree", "LeafCount").await); + let tree_depth = + as_count(plain_value(&runtime, parsed, "ZkTree", "Depth").await).map(|d| d as u32); + let tree_root = plain_value(&runtime, parsed, "ZkTree", "Root") + .await + .and_then(|v| v.as_str().map(str::to_owned)); + let (spent, complete) = + match map_size(&runtime, parsed, "Wormhole", "UsedNullifiers").await { + Some((n, complete)) => (Some(n), complete), + None => (None, false), + }; + (notes_created, tree_depth, tree_root, spent, complete) + } + None => (None, None, None, None, false), + }; + + Ok(Json(WormholeSummary { + chain: id, + notes_created, + notes_spent, + notes_spent_complete, + tree_depth, + tree_root, + volume_fees_paid: fired("MinerVolumeFeePaid"), + segments_denied: fired("SegmentsDenied"), + exit_mint_failed: fired("ExitMintFailed"), + routes, + days, + indexed_from: scan.map(|s| s.0), + indexed_to: scan.map(|s| s.1), + })) +} + /// Miners the distribution table covers. /// /// Enough that the long tail is visible beside the head: a single row reading diff --git a/crates/blackbeard-data/src/store.rs b/crates/blackbeard-data/src/store.rs index 6e7b092..6d52ace 100644 --- a/crates/blackbeard-data/src/store.rs +++ b/crates/blackbeard-data/src/store.rs @@ -60,6 +60,44 @@ pub struct StoreConfig { pub max_connections: u32, } +/// The `from` a wormhole transfer carries when the chain records no sender. +/// +/// Not a real account: it is the sentinel the pallet writes in place of an +/// origin, and it appears on every transfer produced by `verify_public_batch`, +/// `verify_private_batch`, and on the mining rewards injected at block +/// initialisation. Treating it as an address would invent one holder for a +/// third of the chain's transfers. +pub const SENTINEL_SENDER: &str = + "0x0101010101010101010101010101010101010101010101010101010101010101"; + +/// Transfers sharing a production route and a sender visibility. +#[derive(Debug, Clone, PartialEq)] +pub struct TransferRoute { + /// `pallet::call` that produced them, or empty for block initialisation. + pub produced_by: String, + /// Whether the chain recorded no sender. + pub sender_hidden: bool, + /// How many. + pub transfers: u64, + /// Distinct recipients. + pub recipients: u64, + /// Summed amount, smallest unit. + pub amount: String, +} + +/// One day of wormhole activity. +#[derive(Debug, Clone, PartialEq)] +pub struct DailyWormhole { + /// Midnight UTC beginning the day. + pub day: DateTime, + /// Notes created — one per transfer. + pub notes: u64, + /// Of those, the ones with no recorded sender. + pub hidden: u64, + /// Summed amount. + pub amount: String, +} + /// What an account has received and sent, over the indexed range. #[derive(Debug, Clone, PartialEq)] pub struct AccountFlows { @@ -2028,6 +2066,106 @@ impl Store { }) } + /// Every wormhole transfer, grouped by how it was produced. + /// + /// The join that makes the chain's privacy legible: an event alone cannot + /// say whether a hidden sender is the chain paying a miner or somebody + /// spending a note, but the extrinsic that produced it can. A hidden sender + /// in block initialisation is a mining reward; one inside + /// `verify_public_batch` or `verify_private_batch` is a real payment whose + /// origin the chain does not record. + pub async fn wormhole_routes(&self, chain: &ChainId) -> Result, DataError> { + let rows = sqlx::query!( + r#" + select + coalesce(x.pallet || '::' || x.call, '') as "produced_by!", + (e.fields->>'from' = $2) as "sender_hidden!", + count(*) as "transfers!", + count(distinct e.fields->>'to') as "recipients!", + coalesce(sum((e.fields->>'amount')::numeric), 0) as "amount!" + from chain_event e + left join chain_extrinsic x + on x.chain = e.chain + and x.height = e.height + and x.extrinsic_index = e.extrinsic_index + where e.chain = $1 + and ( (e.pallet = 'Wormhole' and e.variant = 'NativeTransferred') + or (e.pallet = 'Balances' and e.variant = 'Transfer')) + group by 1, 2 + order by count(*) desc + "#, + chain.as_str(), + SENTINEL_SENDER, + ) + .fetch_all(&self.pool) + .await?; + + Ok(rows + .into_iter() + .map(|r| TransferRoute { + produced_by: r.produced_by, + sender_hidden: r.sender_hidden, + transfers: r.transfers as u64, + recipients: r.recipients as u64, + amount: r.amount.to_string(), + }) + .collect()) + } + + /// Notes created per day, from the `leaf_index` the transfers carry. + /// + /// Derived from transfers rather than from `ZkTree::LeafInserted`, which is + /// in `SKIPPED_EVENTS` — it fires every block and names no account, so it is + /// not indexed and never will be. The transfers carry the leaf index + /// anyway, which is the same fact arriving by a cheaper road. + pub async fn wormhole_by_day( + &self, + chain: &ChainId, + days: i32, + ) -> Result, DataError> { + let rows = sqlx::query!( + r#" + with span as ( + select generate_series( + date_trunc('day', now()) - make_interval(days => $3::int - 1), + date_trunc('day', now()), + interval '1 day' + ) as day + ), + moves as ( + select date_trunc('day', at) as day, + fields->>'from' = $2 as hidden, + (fields->>'amount')::numeric as amount + from chain_event + where chain = $1 and at is not null + and pallet = 'Wormhole' and variant = 'NativeTransferred' + ) + select span.day as "day!", + coalesce((select count(*) from moves m where m.day = span.day), 0) as "notes!", + coalesce((select sum(amount) from moves m where m.day = span.day), 0) as "amount!", + coalesce((select count(*) from moves m + where m.day = span.day and m.hidden), 0) as "hidden!" + from span + order by span.day asc + "#, + chain.as_str(), + SENTINEL_SENDER, + days, + ) + .fetch_all(&self.pool) + .await?; + + Ok(rows + .into_iter() + .map(|r| DailyWormhole { + day: r.day, + notes: r.notes as u64, + hidden: r.hidden as u64, + amount: r.amount.to_string(), + }) + .collect()) + } + /// Flows for many accounts at once. /// /// One query rather than one per row: a distribution table asks the same diff --git a/crates/blackbeard-entities/src/chain.rs b/crates/blackbeard-entities/src/chain.rs index 6563475..96b0f59 100644 --- a/crates/blackbeard-entities/src/chain.rs +++ b/crates/blackbeard-entities/src/chain.rs @@ -412,3 +412,88 @@ pub struct Distribution { #[ts(type = "number")] pub indexed_to: Option, } + +/// The wormhole: what enters, what leaves, and what is still inside. +/// +/// This chain's transfers are shielded. A transfer inserts a commitment as a +/// **note** in the zk tree; spending one proves a **nullifier**, which the +/// runtime records so it cannot be spent twice. The difference between the two +/// counts is what has not yet moved. +/// +/// **A wormhole transfer records its recipient and its amount, and not its +/// sender.** That is the chain working as designed, and it is why the +/// distribution table's outflow column can only ever speak for the transfers +/// that named an origin. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "WormholeSummary.ts")] +pub struct WormholeSummary { + /// Which chain. + pub chain: ChainId, + /// Notes ever created — `ZkTree::LeafCount`, one per transfer. + #[ts(type = "number")] + pub notes_created: Option, + /// Notes spent — the size of `Wormhole::UsedNullifiers`. + #[ts(type = "number")] + pub notes_spent: Option, + /// Whether that count reached the end of the map rather than a ceiling. + pub notes_spent_complete: bool, + /// Depth of the zk tree. + #[ts(type = "number")] + pub tree_depth: Option, + /// Its current root. + pub tree_root: Option, + /// Fees paid by miners on transfer volume, over the indexed range. + #[ts(type = "number")] + pub volume_fees_paid: u64, + /// Times a proof was rejected. Never, so far — a zero here is a finding. + #[ts(type = "number")] + pub segments_denied: u64, + /// Times an exit failed to mint. + #[ts(type = "number")] + pub exit_mint_failed: u64, + /// Every transfer route, busiest first. + pub routes: Vec, + /// Notes created per day, oldest first. + pub days: Vec, + /// Lowest block whose events this observer has read. + #[ts(type = "number")] + pub indexed_from: Option, + /// Highest. + #[ts(type = "number")] + pub indexed_to: Option, +} + +/// Transfers sharing a production route and a sender visibility. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "TransferRoute.ts")] +pub struct TransferRoute { + /// The `pallet::call` that produced them. Empty for block initialisation, + /// which on this chain means a mining reward. + pub produced_by: String, + /// Whether the chain recorded no sender for these. + pub sender_hidden: bool, + /// How many transfers took this route. + #[ts(type = "number")] + pub transfers: u64, + /// Distinct recipients they reached. + #[ts(type = "number")] + pub recipients: u64, + /// Their summed amount, smallest unit. + pub amount: BigUintDec, +} + +/// One day of wormhole activity. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "DailyWormhole.ts")] +pub struct DailyWormhole { + /// Midnight UTC beginning the day. + pub day: DateTime, + /// Notes created — one per transfer. + #[ts(type = "number")] + pub notes: u64, + /// Of those, the ones with no recorded sender. + #[ts(type = "number")] + pub hidden: u64, + /// Summed amount. + pub amount: BigUintDec, +} diff --git a/crates/blackbeard-entities/src/lib.rs b/crates/blackbeard-entities/src/lib.rs index 9548f56..80f56fb 100644 --- a/crates/blackbeard-entities/src/lib.rs +++ b/crates/blackbeard-entities/src/lib.rs @@ -33,7 +33,8 @@ pub use block::{BlockDetail, BlockEvent, BlockExtrinsic, BlockObservation, Recen pub use call::{CallIndex, CallSummary, EventSummary}; pub use chain::{ AccountFlows, ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, DailyActivity, - Distribution, LockCounts, MinerFlow, NetworkSummary, Tracking, + DailyWormhole, Distribution, LockCounts, MinerFlow, NetworkSummary, Tracking, TransferRoute, + WormholeSummary, }; pub use error::{ApiError, EntityError}; pub use miner::{ diff --git a/web/src/App.tsx b/web/src/App.tsx index a6a1d1b..6f3c1ca 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -24,6 +24,7 @@ import { Leaderboard } from './components/Leaderboard' import { MinerPanel } from './components/MinerPanel' import { RuntimePanel } from './components/RuntimePanel' import { NetworkPanel } from './components/NetworkPanel' +import { WormholePanel } from './components/WormholePanel' import { NodeRoute } from './components/NodeRoute' import { NodesIndex } from './components/NodesIndex' import { RuntimesIndex } from './components/RuntimesIndex' @@ -269,6 +270,13 @@ export default function App() { )} {route.index === 'node' && chain && } + {route.index === 'wormhole' && chain && ( + + )} {route.index === 'network' && chain && ( , +/** + * Notes created per day, oldest first. + */ +days: Array, +/** + * Lowest block whose events this observer has read. + */ +indexed_from: number, +/** + * Highest. + */ +indexed_to: number, }; diff --git a/web/src/api/rest.ts b/web/src/api/rest.ts index c23ccdd..706507f 100644 --- a/web/src/api/rest.ts +++ b/web/src/api/rest.ts @@ -22,6 +22,7 @@ import type { RecentBlock } from './generated/RecentBlock' import type { MinerDetail } from './generated/MinerDetail' import type { Distribution } from './generated/Distribution' import type { NetworkSummary } from './generated/NetworkSummary' +import type { WormholeSummary } from './generated/WormholeSummary' import type { NodeIndex } from './generated/NodeIndex' import type { NodeInfo } from './generated/NodeInfo' import type { RuntimeDetail } from './generated/RuntimeDetail' @@ -266,3 +267,8 @@ export async function fetchDistribution( ): Promise { return get(`/chains/${chain}/distribution?window=${window}`, signal) } + +/** What enters the wormhole, what leaves, and what is still inside. */ +export async function fetchWormhole(chain: string, signal?: AbortSignal): Promise { + return get(`/chains/${chain}/wormhole`, signal) +} diff --git a/web/src/components/WormholePanel.tsx b/web/src/components/WormholePanel.tsx new file mode 100644 index 0000000..a9d1721 --- /dev/null +++ b/web/src/components/WormholePanel.tsx @@ -0,0 +1,217 @@ +/** + * The wormhole: what enters, what leaves, and what is still inside. + * + * This chain's transfers are shielded. A transfer inserts a commitment as a + * **note**; spending one proves a **nullifier**, which the runtime records so it + * cannot be spent twice. Notes created minus notes spent is what has not yet + * moved — and every one of those, when it does move, will name a recipient and + * an amount and **not a sender**. + * + * That last fact is why this page exists. The distribution table shipped with an + * outflow column reading zero for every miner, which looked like evidence that + * nothing had left those addresses. It was not: it was the chain's privacy doing + * its job, and the observer had nothing anywhere that said so. This page is the + * honest account of how much of the chain is unobservable and by what route. + */ + +import { useEffect, useState } from 'react' + +import type { WormholeSummary } from '../api/generated/WormholeSummary' +import { RequestFailed, fetchWormhole } from '../api/rest' +import { height as fmtHeight, tokens } from '../lib/format' + +function Field({ + label, + value, + note, +}: { + label: string + value: string + note?: string | undefined +}) { + return ( +
+
{label}
+
{value}
+ {note &&
{note}
} +
+ ) +} + +/** The route's own name, since block initialisation has no extrinsic. */ +function routeName(produced_by: string): string { + return produced_by === '' ? 'block initialisation' : produced_by +} + +/** What a route actually is, in a few words, because `Utility::batch_all` does + * not tell a reader whether their money can be traced. */ +function routeMeaning(produced_by: string, hidden: boolean): string { + if (produced_by === '') + return hidden ? 'mining rewards, paid by the chain' : 'block initialisation' + if (produced_by.startsWith('Wormhole::')) return 'a shielded spend — no sender recorded' + return hidden ? 'no sender recorded' : 'an ordinary transfer, sender recorded' +} + +export function WormholePanel({ + chain, + decimals, + symbol, +}: { + chain: string + decimals: number + symbol: string +}) { + const [data, setData] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + const controller = new AbortController() + setData(null) + setError(null) + fetchWormhole(chain, controller.signal) + .then(setData) + .catch((e: unknown) => { + if (controller.signal.aborted) return + setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.') + }) + return () => controller.abort() + }, [chain]) + + if (error) return

{error}

+ if (!data) return

Counting notes…

+ + const outstanding = + data.notes_created !== null && data.notes_spent !== null + ? data.notes_created - data.notes_spent + : null + + const hiddenTransfers = data.routes + .filter((r) => r.sender_hidden && r.produced_by !== '') + .reduce((sum, r) => sum + r.transfers, 0) + const visibleTransfers = data.routes + .filter((r) => !r.sender_hidden) + .reduce((sum, r) => sum + r.transfers, 0) + + return ( + <> +
+
+

The wormhole

+ read from chain state +
+
+ + + + + + +
+

+ A transfer on this chain inserts a commitment — a note — and spending one proves + a nullifier, which the runtime records so the same note cannot be spent twice. + Notes created less notes spent is what has not yet moved.{' '} + + Every one of those, when it moves, will record a recipient and an amount and not a + sender. + {' '} + A zero under proofs rejected is a finding rather than a gap: it says no proof has ever + been refused, which is a different statement from nobody having tried. +

+
+ +
+
+

How value moves

+ + amounts in {symbol} ·{' '} + {data.indexed_from !== null && data.indexed_to !== null + ? `blocks ${fmtHeight(data.indexed_from)}–${fmtHeight(data.indexed_to)}` + : 'nothing indexed yet'} + +
+
+ + + + + + + + + + + + + {data.routes.map((r) => ( + + + + + + + + ))} + +
+ Every transfer route, and whether the chain recorded who sent it +
+ Produced by + + Sender + TransfersRecipientsAmount
+
+ {routeName(r.produced_by)} + + {routeMeaning(r.produced_by, r.sender_hidden)} + +
+
+ {r.sender_hidden ? ( + hidden + ) : ( + recorded + )} + {fmtHeight(r.transfers)}{fmtHeight(r.recipients)}{tokens(r.amount, decimals, 1)}
+
+

+ + {fmtHeight(hiddenTransfers)} transfers carried no sender, against{' '} + {fmtHeight(visibleTransfers)} that did. + {' '} + The hidden ones produced by Wormhole::verify_public_batch and{' '} + Wormhole::verify_private_batch are real payments whose origin the chain does + not write down — this is the privacy feature, not a gap in the index. Hidden transfers + from block initialisation are something else: they are the chain paying miners, + and their sender is the runtime rather than anybody. +

+

+ This is the reason the distribution table can only speak about outflow it can see. An + address that has paid out through the wormhole shows nothing there, and that is the chain + behaving correctly rather than the address behaving suspiciously. +

+
+ + ) +} diff --git a/web/src/lib/routes.ts b/web/src/lib/routes.ts index 36ec841..1eaf25a 100644 --- a/web/src/lib/routes.ts +++ b/web/src/lib/routes.ts @@ -106,7 +106,16 @@ export interface Route { /** The kinds that have an index. */ export type SectionName = - 'block' | 'call' | 'event' | 'account' | 'reversible' | 'state' | 'runtime' | 'node' | 'network' + | 'block' + | 'call' + | 'event' + | 'account' + | 'reversible' + | 'state' + | 'runtime' + | 'node' + | 'network' + | 'wormhole' /** * The sections, in the order the nav shows them. @@ -130,6 +139,10 @@ export const SECTIONS: { id: SectionName; label: string }[] = [ // Last: the nav reads "this chain's ledger, then the machines watching it, // then the economy over all of it". { id: 'network', label: 'Network' }, + // Beside the network page because it answers the same kind of question — what + // is this economy doing — and because the two are read together: the + // distribution table's blindness is explained here. + { id: 'wormhole', label: 'Wormhole' }, ] function section(name: string): SectionName | null {