diff --git a/.sqlx/query-5dfe48cf0681d8946c77c2d6904d8b5590c86b3cb8f9809fb3eec479150534be.json b/.sqlx/query-5dfe48cf0681d8946c77c2d6904d8b5590c86b3cb8f9809fb3eec479150534be.json
new file mode 100644
index 0000000..9041e7e
--- /dev/null
+++ b/.sqlx/query-5dfe48cf0681d8946c77c2d6904d8b5590c86b3cb8f9809fb3eec479150534be.json
@@ -0,0 +1,41 @@
+{
+ "db_name": "PostgreSQL",
+ "query": "\n with ev as (\n select e.height, e.extrinsic_index,\n e.fields->>'to' as recipient,\n (e.fields->>'amount')::numeric as amount\n from chain_event e\n 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'\n and e.variant = 'NativeTransferred'\n and x.pallet = 'Wormhole'\n and x.call = 'verify_public_batch'\n ),\n batches as (\n select count(distinct (height, extrinsic_index))::numeric as n from ev\n ),\n per as (\n select recipient,\n count(distinct (height, extrinsic_index)) as credits,\n sum(amount) as amount\n from ev group by 1\n ),\n miners as (\n select distinct fields->>'miner' as address\n from chain_event\n where chain = $1\n and pallet = 'MiningRewards'\n and variant = 'MinerRewarded'\n ),\n classed as (\n select p.credits, p.amount,\n case\n when (select n from batches) > 0\n and p.credits::float8 / (select n from batches)::float8 > $2\n then 'infrastructure'\n when p.recipient in (select address from miners)\n then 'miner_fee'\n when p.credits = 1 then 'paid_once'\n when p.credits between 2 and 4 then 'paid_a_few'\n when p.credits between 5 and 19 then 'paid_often'\n else 'paid_regularly'\n end as kind\n from per p\n )\n select kind as \"kind!\",\n count(*) as \"addresses!\",\n sum(credits)::bigint as \"credits!\",\n coalesce(sum(amount), 0) as \"amount!\"\n from classed\n group by 1\n ",
+ "describe": {
+ "columns": [
+ {
+ "ordinal": 0,
+ "name": "kind!",
+ "type_info": "Text"
+ },
+ {
+ "ordinal": 1,
+ "name": "addresses!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 2,
+ "name": "credits!",
+ "type_info": "Int8"
+ },
+ {
+ "ordinal": 3,
+ "name": "amount!",
+ "type_info": "Numeric"
+ }
+ ],
+ "parameters": {
+ "Left": [
+ "Text",
+ "Float8"
+ ]
+ },
+ "nullable": [
+ null,
+ null,
+ null,
+ null
+ ]
+ },
+ "hash": "5dfe48cf0681d8946c77c2d6904d8b5590c86b3cb8f9809fb3eec479150534be"
+}
diff --git a/CLAUDE.md b/CLAUDE.md
index 7c43ecd..7ae8f3a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -652,6 +652,38 @@ paying a miner, and the "sender" is the runtime. Inside `verify_public_batch` or
is not recorded. Collapsing the two would make mining rewards look like
anonymous payments.
+**Exit batches look exactly like payroll and mostly are not.** Grouping
+`NativeTransferred` by producing extrinsic gives one sender, many recipients,
+repeating — and the obvious reading of it is wrong twice over.
+
+*Batch size is aggregation, not generosity.* The pallet documents a public batch
+as one segment per inner private batch, and a segment as one client. A 120-output
+batch is an aggregator bundling unrelated people, so size measures aggregator
+throughput. It is deliberately not reported anywhere on the site.
+
+*The most recurrent recipient is the aggregator.* The address credited in 774
+batches is in **91.5% of every public batch anyone submitted and 0% of the
+private ones** — that is the rebate `settle_exit_bundle` mints to the proof's
+`aggregator_address`. QUANPOOL's mining address appears in 501 batches for 7.54
+QTC total, 0.015 each: the miner fee share credited to whoever included the
+batch. Classified, **three addresses take 76% of everything the exits pay**:
+
+ infrastructure 3 addresses 1,647 credits 4,018.9 QTC 2.44 each
+ recipient 515 addresses 3,167 credits 1,233.9 QTC 0.39 each
+ miner fee share 21 addresses 243 credits 5.2 QTC 0.02 each
+
+`exit_cohorts` does that split, and `INFRASTRUCTURE_SHARE` is the threshold —
+credited in more than a fifth of all batches. The line is nowhere near anything:
+the real values are 91.5% on one side and single-digit percentages on the other.
+
+What survives is genuine and is the reason the table ships: among real
+recipients, **35 addresses paid twenty or more times take two thirds of the
+remaining value**, at twice everyone else's average credit. That is a
+recurring-payment shape and *no more than that* — the chain records no sender, so
+nothing names a payer, and a regular payee is as plausibly an exchange deposit or
+one person on a schedule as a pool meeting its obligations. Say that on the page
+every time the table is shown.
+
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
diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs
index aec4f99..2d63372 100644
--- a/crates/blackbeard-api/src/routes.rs
+++ b/crates/blackbeard-api/src/routes.rs
@@ -18,7 +18,7 @@ use axum::{Json, Router};
use blackbeard_entities::{
AccountDetail, AccountEvent, AccountFlows, AccountRoleEntry, AccountRow, ActivitySource,
ApiError, BigUintDec, BlockDetail, CallIndex, CallSummary, ChainInfo, ChainRoles, ChainSeries,
- ChainState, ChainSummary, DailyActivity, DailyWormhole, Distribution, EventSummary,
+ ChainState, ChainSummary, DailyActivity, DailyWormhole, Distribution, EventSummary, ExitCohort,
GenesisDetail, LeaderboardRow, LockCounts, MinerDetail, MinerFlow, MinerId, MinerSeriesPoint,
NamedAccount, NetworkSummary, NodeActivity, NodeIndex, NodeInfo, NodeRow, PalletPot,
PendingTransfer, RecentBlock, ReversibleState, RewardSummary, RoleSource, RuntimeConstant,
@@ -821,6 +821,19 @@ async fn wormhole(
amount: BigUintDec(r.amount),
})
.collect();
+ let cohorts = state
+ .store
+ .exit_cohorts(&id)
+ .await
+ .map_err(database_unavailable)?
+ .into_iter()
+ .map(|c| ExitCohort {
+ kind: c.kind,
+ addresses: c.addresses,
+ credits: c.credits,
+ amount: BigUintDec(c.amount),
+ })
+ .collect();
let days = state
.store
.wormhole_by_day(&id, WORMHOLE_DAYS)
@@ -886,6 +899,7 @@ async fn wormhole(
segments_denied: fired("SegmentsDenied"),
exit_mint_failed: fired("ExitMintFailed"),
routes,
+ cohorts,
days,
indexed_from: scan.map(|s| s.0),
indexed_to: scan.map(|s| s.1),
diff --git a/crates/blackbeard-data/src/store.rs b/crates/blackbeard-data/src/store.rs
index 6d52ace..6084abc 100644
--- a/crates/blackbeard-data/src/store.rs
+++ b/crates/blackbeard-data/src/store.rs
@@ -85,6 +85,35 @@ pub struct TransferRoute {
pub amount: String,
}
+/// The share of all batches above which a credited address is the protocol's
+/// own plumbing rather than anybody's payee.
+///
+/// An aggregator's rebate is minted on every bundle it submits, so it appears
+/// in a proportion of batches no recipient ever could: measured on mainnet, one
+/// address is credited in **91.5%** of every public batch submitted by anyone,
+/// and in none of the private ones. A fifth is far above any plausible payee and
+/// far below that, so the classification does not turn on where exactly the line
+/// falls.
+const INFRASTRUCTURE_SHARE: f64 = 0.20;
+
+/// Credits out of the wormhole, grouped by what the address appears to be.
+///
+/// One row per class, in the order they should be read: the two machinery
+/// classes first, because they carry three quarters of the value and a table
+/// that leads with cohorts is describing aggregator plumbing and calling it an
+/// economy.
+#[derive(Debug, Clone, PartialEq)]
+pub struct ExitCohort {
+ /// Which class. Stable identifiers, not display strings.
+ pub kind: String,
+ /// How many distinct addresses fall in it.
+ pub addresses: u64,
+ /// How many times they were credited.
+ pub credits: u64,
+ /// Summed amount, smallest unit.
+ pub amount: String,
+}
+
/// One day of wormhole activity.
#[derive(Debug, Clone, PartialEq)]
pub struct DailyWormhole {
@@ -2112,6 +2141,124 @@ impl Store {
.collect())
}
+ /// Who the wormhole exits pay, once the protocol's own plumbing is set aside.
+ ///
+ /// The premise this answers is that a pool paying out must leave a pattern
+ /// in the batches. A pattern is there and **most of it is not payouts**, so
+ /// the classification is the whole point of the query rather than a
+ /// refinement of it:
+ ///
+ /// - `infrastructure` — credited in more than [`INFRASTRUCTURE_SHARE`] of
+ /// every batch anyone submitted. That is the aggregator rebate
+ /// `settle_exit_bundle` mints to the proof's `aggregator_address`. Three
+ /// such addresses take 76% of everything the exits pay.
+ /// - `miner_fee` — an address the chain has paid a mining reward to,
+ /// receiving the fee share credited to whichever block author included
+ /// the batch. Frequent and tiny: 0.015 QTC a time on mainnet.
+ /// - the four `paid_*` bands — everyone else, by how often they were paid.
+ ///
+ /// Only `verify_public_batch` is counted. A private batch is one client's
+ /// own exit (measured: 1.0 outputs on average, never more than two), so it
+ /// carries no cohort at all, and mixing the two would halve every average
+ /// for no gain.
+ ///
+ /// Batch *size* is deliberately not reported as a cohort. The pallet
+ /// documents a public batch as one segment per inner private batch and a
+ /// segment as one client, so a 120-output batch is an aggregator bundling
+ /// unrelated people — it measures aggregator throughput, and reading it as
+ /// one payer paying 120 payees is the mistake this query exists to avoid.
+ pub async fn exit_cohorts(&self, chain: &ChainId) -> Result
| + Who + | +Addresses | +Credits | +Amount | +Each | +
|---|---|---|---|---|
|
+
+ {meta?.label ?? c.kind}
+ {meta?.meaning ? {meta.meaning} : null}
+
+ |
+ {fmtHeight(c.addresses)} | +{fmtHeight(c.credits)} | +{tokens(c.amount, decimals, 1)} | +{each(c.amount, c.credits)} | +
+ + Most of what leaves the wormhole is the protocol paying itself + {machineryShare !== null && `, ${machineryShare}% of it`}. + {' '} + An aggregator's rebate is minted on every bundle it submits, so a handful of + addresses appear in almost every batch anyone sent — one of them in 91.5% of them, and in + none of the private ones. A miner's fee share goes to whoever included the batch, + which is why a mining address can be credited hundreds of times for a few hundredths of a{' '} + {symbol} each. Neither is anybody's payout. +
++ Batch size is deliberately not on this page. A public batch carries one segment + per inner private batch and a segment is one client, so a 120-output batch is an + aggregator bundling unrelated people — it measures aggregator throughput, and reading it + as one payer paying a hundred payees is the mistake this table exists to prevent. +
++ What survives the subtraction is the bottom rows, and they are the real finding: a small + number of addresses paid over and over, taking most of the remaining value at a larger + amount each time.{' '} + That is a recurring-payment shape, and no more than that. The chain + records no sender, so nothing here names a payer — and a regular payee could as easily be + an exchange deposit, or one person moving their own funds on a schedule, as a pool meeting + its obligations. +
+