feat: a wormhole page — what enters, what leaves, what is still inside
deploy / build (push) Successful in 7m33s
deploy / deploy-api (push) Successful in 17s
deploy / deploy-web (push) Successful in 5s

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 09:32:27 +03:00
co-authored by Claude Opus 5
parent 365cd7a803
commit e3c95d4945
14 changed files with 825 additions and 8 deletions
@@ -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"
}
@@ -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"
}
+32
View File
@@ -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
+112 -6
View File
@@ -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<AppState>,
Path(chain): Path<String>,
) -> Result<Json<WormholeSummary>, 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<String, u64> = 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
+138
View File
@@ -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<Utc>,
/// 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<Vec<TransferRoute>, 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<Vec<DailyWormhole>, 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
+85
View File
@@ -412,3 +412,88 @@ pub struct Distribution {
#[ts(type = "number")]
pub indexed_to: Option<u64>,
}
/// 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<u64>,
/// Notes spent — the size of `Wormhole::UsedNullifiers`.
#[ts(type = "number")]
pub notes_spent: Option<u32>,
/// 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<u32>,
/// Its current root.
pub tree_root: Option<String>,
/// 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<TransferRoute>,
/// Notes created per day, oldest first.
pub days: Vec<DailyWormhole>,
/// Lowest block whose events this observer has read.
#[ts(type = "number")]
pub indexed_from: Option<u64>,
/// Highest.
#[ts(type = "number")]
pub indexed_to: Option<u64>,
}
/// 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<Utc>,
/// 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,
}
+2 -1
View File
@@ -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::{
+8
View File
@@ -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() {
<RuntimesIndex chain={chain} current={state.summary?.spec_version ?? null} />
)}
{route.index === 'node' && chain && <NodesIndex chain={chain} />}
{route.index === 'wormhole' && chain && (
<WormholePanel
chain={chain}
decimals={info?.token_decimals ?? 12}
symbol={info?.token_symbol ?? ''}
/>
)}
{route.index === 'network' && chain && (
<NetworkPanel
chain={chain}
+23
View File
@@ -0,0 +1,23 @@
// 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";
/**
* One day of wormhole activity.
*/
export type DailyWormhole = {
/**
* Midnight UTC beginning the day.
*/
day: string,
/**
* Notes created — one per transfer.
*/
notes: number,
/**
* Of those, the ones with no recorded sender.
*/
hidden: number,
/**
* Summed amount.
*/
amount: BigUintDec, };
+28
View File
@@ -0,0 +1,28 @@
// 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";
/**
* Transfers sharing a production route and a sender visibility.
*/
export type TransferRoute = {
/**
* The `pallet::call` that produced them. Empty for block initialisation,
* which on this chain means a mining reward.
*/
produced_by: string,
/**
* Whether the chain recorded no sender for these.
*/
sender_hidden: boolean,
/**
* How many transfers took this route.
*/
transfers: number,
/**
* Distinct recipients they reached.
*/
recipients: number,
/**
* Their summed amount, smallest unit.
*/
amount: BigUintDec, };
+71
View File
@@ -0,0 +1,71 @@
// 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 { DailyWormhole } from "./DailyWormhole";
import type { TransferRoute } from "./TransferRoute";
/**
* 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.
*/
export type WormholeSummary = {
/**
* Which chain.
*/
chain: ChainId,
/**
* Notes ever created — `ZkTree::LeafCount`, one per transfer.
*/
notes_created: number,
/**
* Notes spent — the size of `Wormhole::UsedNullifiers`.
*/
notes_spent: number,
/**
* Whether that count reached the end of the map rather than a ceiling.
*/
notes_spent_complete: boolean,
/**
* Depth of the zk tree.
*/
tree_depth: number,
/**
* Its current root.
*/
tree_root: string | null,
/**
* Fees paid by miners on transfer volume, over the indexed range.
*/
volume_fees_paid: number,
/**
* Times a proof was rejected. Never, so far — a zero here is a finding.
*/
segments_denied: number,
/**
* Times an exit failed to mint.
*/
exit_mint_failed: number,
/**
* Every transfer route, busiest first.
*/
routes: Array<TransferRoute>,
/**
* Notes created per day, oldest first.
*/
days: Array<DailyWormhole>,
/**
* Lowest block whose events this observer has read.
*/
indexed_from: number,
/**
* Highest.
*/
indexed_to: number, };
+6
View File
@@ -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<Distribution> {
return get<Distribution>(`/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<WormholeSummary> {
return get<WormholeSummary>(`/chains/${chain}/wormhole`, signal)
}
+217
View File
@@ -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 (
<div className="node-field">
<div className="eyebrow">{label}</div>
<div className="node-value">{value}</div>
{note && <div className="stat-note">{note}</div>}
</div>
)
}
/** 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<WormholeSummary | null>(null)
const [error, setError] = useState<string | null>(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 <p className="empty">{error}</p>
if (!data) return <p className="empty">Counting notes…</p>
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 (
<>
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">The wormhole</h2>
<span className="eyebrow">read from chain state</span>
</div>
<div className="node-grid">
<Field
label="Notes created"
value={data.notes_created === null ? '—' : fmtHeight(data.notes_created)}
note="one per transfer, ever"
/>
<Field
label="Notes spent"
value={data.notes_spent === null ? '—' : fmtHeight(data.notes_spent)}
note={
data.notes_spent_complete ? 'nullifiers, counted in full' : 'count hit its ceiling'
}
/>
<Field
label="Outstanding"
value={outstanding === null ? '—' : fmtHeight(outstanding)}
note="unspent — still inside"
/>
<Field
label="Tree depth"
value={data.tree_depth === null ? '—' : String(data.tree_depth)}
/>
<Field
label="Proofs rejected"
value={fmtHeight(data.segments_denied)}
note="segments denied"
/>
<Field label="Exits that failed to mint" value={fmtHeight(data.exit_mint_failed)} />
</div>
<p className="panel-note">
A transfer on this chain inserts a commitment — a <em>note</em> — and spending one proves
a <em>nullifier</em>, which the runtime records so the same note cannot be spent twice.
Notes created less notes spent is what has not yet moved.{' '}
<strong>
Every one of those, when it moves, will record a recipient and an amount and not a
sender.
</strong>{' '}
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.
</p>
</section>
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">How value moves</h2>
<span className="eyebrow">
amounts in {symbol} ·{' '}
{data.indexed_from !== null && data.indexed_to !== null
? `blocks ${fmtHeight(data.indexed_from)}–${fmtHeight(data.indexed_to)}`
: 'nothing indexed yet'}
</span>
</div>
<div className="scroll-x">
<table className="board">
<caption className="visually-hidden">
Every transfer route, and whether the chain recorded who sent it
</caption>
<thead>
<tr>
<th scope="col" className="left">
Produced by
</th>
<th scope="col" className="left">
Sender
</th>
<th scope="col">Transfers</th>
<th scope="col">Recipients</th>
<th scope="col">Amount</th>
</tr>
</thead>
<tbody>
{data.routes.map((r) => (
<tr key={`${r.produced_by}-${String(r.sender_hidden)}`}>
<td className="left">
<div className="call-cell">
<code>{routeName(r.produced_by)}</code>
<span className="runtime-doc">
{routeMeaning(r.produced_by, r.sender_hidden)}
</span>
</div>
</td>
<td className="left">
{r.sender_hidden ? (
<span className="chip chip-failed">hidden</span>
) : (
<span className="runtime-modifier">recorded</span>
)}
</td>
<td className="numeral">{fmtHeight(r.transfers)}</td>
<td className="numeral">{fmtHeight(r.recipients)}</td>
<td className="numeral">{tokens(r.amount, decimals, 1)}</td>
</tr>
))}
</tbody>
</table>
</div>
<p className="panel-note">
<strong>
{fmtHeight(hiddenTransfers)} transfers carried no sender, against{' '}
{fmtHeight(visibleTransfers)} that did.
</strong>{' '}
The hidden ones produced by <code>Wormhole::verify_public_batch</code> and{' '}
<code>Wormhole::verify_private_batch</code> 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 <em>block initialisation</em> are something else: they are the chain paying miners,
and their sender is the runtime rather than anybody.
</p>
<p className="panel-note">
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.
</p>
</section>
</>
)
}
+14 -1
View File
@@ -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 {