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 {