feat: who the wormhole exits pay, and how much of it is the protocol itself
All checks were successful
deploy / build (push) Successful in 7m37s
deploy / deploy-web (push) Successful in 5s
deploy / deploy-api (push) Successful in 16s

The premise was that pools are the best surface for observing wormhole
transfers, because payouts must leave a pattern in the batches. The pattern
is there. Most of it is not payouts.

Two things break the obvious reading. Batch *size* is aggregation: 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. And the most recurrent recipient is the aggregator itself — one
address is credited in 91.5% of every public batch anyone submitted and in
none of the private ones, which is the rebate minted to the proof's
`aggregator_address`. QUANPOOL's mining address appears in 501 batches for
7.54 QTC total, 0.015 each: the fee share paid to whoever included the batch.

Classified, three addresses take 76% of everything the exits pay. A cohort
table that did not say so would be describing aggregator plumbing and calling
it an economy, so the share is the section's eyebrow and its first sentence.

What survives the subtraction is the finding worth shipping: among genuine
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 the page says it is no more than that — the chain
records no sender, so a regular payee is as plausibly an exchange deposit or
one person on a schedule as a pool meeting its obligations.

Batch size is not rendered anywhere, on purpose.

Closes #20

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 11:49:48 +03:00
parent 4d9d41888a
commit f81ebd4746
9 changed files with 471 additions and 3 deletions

View File

@@ -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"
}

View File

@@ -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

View File

@@ -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),

View File

@@ -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<Vec<ExitCohort>, DataError> {
let rows = sqlx::query!(
r#"
with ev as (
select e.height, e.extrinsic_index,
e.fields->>'to' as recipient,
(e.fields->>'amount')::numeric as amount
from chain_event e
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'
and x.pallet = 'Wormhole'
and x.call = 'verify_public_batch'
),
batches as (
select count(distinct (height, extrinsic_index))::numeric as n from ev
),
per as (
select recipient,
count(distinct (height, extrinsic_index)) as credits,
sum(amount) as amount
from ev group by 1
),
miners as (
select distinct fields->>'miner' as address
from chain_event
where chain = $1
and pallet = 'MiningRewards'
and variant = 'MinerRewarded'
),
classed as (
select p.credits, p.amount,
case
when (select n from batches) > 0
and p.credits::float8 / (select n from batches)::float8 > $2
then 'infrastructure'
when p.recipient in (select address from miners)
then 'miner_fee'
when p.credits = 1 then 'paid_once'
when p.credits between 2 and 4 then 'paid_a_few'
when p.credits between 5 and 19 then 'paid_often'
else 'paid_regularly'
end as kind
from per p
)
select kind as "kind!",
count(*) as "addresses!",
sum(credits)::bigint as "credits!",
coalesce(sum(amount), 0) as "amount!"
from classed
group by 1
"#,
chain.as_str(),
INFRASTRUCTURE_SHARE,
)
.fetch_all(&self.pool)
.await?;
// Ordered here rather than in SQL: the reading order is editorial — the
// machinery first, because it carries three quarters of the value and a
// table that opens with cohorts invites the wrong conclusion — and a
// `case` in an `order by` would hide that decision in the query.
const ORDER: [&str; 6] = [
"infrastructure",
"miner_fee",
"paid_once",
"paid_a_few",
"paid_often",
"paid_regularly",
];
let mut cohorts: Vec<ExitCohort> = rows
.into_iter()
.map(|r| ExitCohort {
kind: r.kind,
addresses: r.addresses as u64,
credits: r.credits as u64,
amount: r.amount.to_string(),
})
.collect();
cohorts.sort_by_key(|c| {
ORDER
.iter()
.position(|k| *k == c.kind)
.unwrap_or(usize::MAX)
});
Ok(cohorts)
}
/// Notes created per day, from the `leaf_index` the transfers carry.
///
/// Derived from transfers rather than from `ZkTree::LeafInserted`, which is

View File

@@ -526,6 +526,8 @@ pub struct WormholeSummary {
pub exit_mint_failed: u64,
/// Every transfer route, busiest first.
pub routes: Vec<TransferRoute>,
/// Who the exits pay, grouped by what each address appears to be.
pub cohorts: Vec<ExitCohort>,
/// Notes created per day, oldest first.
pub days: Vec<DailyWormhole>,
/// Lowest block whose events this observer has read.
@@ -536,6 +538,47 @@ pub struct WormholeSummary {
pub indexed_to: Option<u64>,
}
/// Credits out of the wormhole, grouped by what the address appears to be.
///
/// This exists because the obvious reading of exit batches is wrong. Grouping
/// them by producing extrinsic gives a shape that looks exactly like payroll —
/// one sender, many recipients, repeating — and almost all of it is the
/// protocol's own plumbing:
///
/// - an **aggregator's** rebate is minted on every bundle it submits, so one
/// address is credited in 91.5% of every public batch anyone sent, and three
/// such addresses take **76% of everything the exits pay**;
/// - a **miner's** fee share goes to whichever block author included the batch,
/// which is frequent and tiny — 0.015 QTC a time.
///
/// Batch size is not reported at all, for the same reason: 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, not a payer's generosity.
///
/// What survives the subtraction is real: among genuine recipients, 35
/// addresses paid twenty or more times take two thirds of the remaining value.
/// That is a recurring-payment shape. It is **consistent with** pool payouts and
/// is not proof of them — the chain records no sender, so nobody can be named,
/// and a regular payee could equally be an exchange deposit or one person moving
/// their own funds on a schedule.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "ExitCohort.ts")]
pub struct ExitCohort {
/// Stable identifier for the class: `infrastructure`, `miner_fee`, or one
/// of `paid_once`, `paid_a_few`, `paid_often`, `paid_regularly`. The label
/// is the frontend's to choose; this is not a display string.
pub kind: String,
/// Distinct addresses in the class.
#[ts(type = "number")]
pub addresses: u64,
/// How many times they were credited.
#[ts(type = "number")]
pub credits: u64,
/// Summed amount, smallest unit.
pub amount: BigUintDec,
}
/// Transfers sharing a production route and a sender visibility.
#[derive(Debug, Clone, Serialize, Deserialize, TS)]
#[ts(export, export_to = "TransferRoute.ts")]

View File

@@ -33,8 +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,
DailyWormhole, Distribution, LockCounts, MinerFlow, NetworkSummary, PalletPot, Tracking,
TransferRoute, WormholeSummary,
DailyWormhole, Distribution, ExitCohort, LockCounts, MinerFlow, NetworkSummary, PalletPot,
Tracking, TransferRoute, WormholeSummary,
};
pub use error::{ApiError, EntityError};
pub use miner::{

View File

@@ -0,0 +1,48 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { BigUintDec } from "./BigUintDec";
/**
* Credits out of the wormhole, grouped by what the address appears to be.
*
* This exists because the obvious reading of exit batches is wrong. Grouping
* them by producing extrinsic gives a shape that looks exactly like payroll —
* one sender, many recipients, repeating — and almost all of it is the
* protocol's own plumbing:
*
* - an **aggregator's** rebate is minted on every bundle it submits, so one
* address is credited in 91.5% of every public batch anyone sent, and three
* such addresses take **76% of everything the exits pay**;
* - a **miner's** fee share goes to whichever block author included the batch,
* which is frequent and tiny — 0.015 QTC a time.
*
* Batch size is not reported at all, for the same reason: 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, not a payer's generosity.
*
* What survives the subtraction is real: among genuine recipients, 35
* addresses paid twenty or more times take two thirds of the remaining value.
* That is a recurring-payment shape. It is **consistent with** pool payouts and
* is not proof of them — the chain records no sender, so nobody can be named,
* and a regular payee could equally be an exchange deposit or one person moving
* their own funds on a schedule.
*/
export type ExitCohort = {
/**
* Stable identifier for the class: `infrastructure`, `miner_fee`, or one
* of `paid_once`, `paid_a_few`, `paid_often`, `paid_regularly`. The label
* is the frontend's to choose; this is not a display string.
*/
kind: string,
/**
* Distinct addresses in the class.
*/
addresses: number,
/**
* How many times they were credited.
*/
credits: number,
/**
* Summed amount, smallest unit.
*/
amount: BigUintDec, };

View File

@@ -1,6 +1,7 @@
// 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 { ExitCohort } from "./ExitCohort";
import type { TransferRoute } from "./TransferRoute";
/**
@@ -57,6 +58,10 @@ exit_mint_failed: number,
* Every transfer route, busiest first.
*/
routes: Array<TransferRoute>,
/**
* Who the exits pay, grouped by what each address appears to be.
*/
cohorts: Array<ExitCohort>,
/**
* Notes created per day, oldest first.
*/

View File

@@ -52,6 +52,36 @@ function routeMeaning(produced_by: string, hidden: boolean): string {
return hidden ? 'no sender recorded' : 'an ordinary transfer, sender recorded'
}
/**
* What to call each cohort class, and what it means.
*
* The identifiers come from the API and are stable; the wording is this
* page's. Kept as one table so the two machinery classes cannot drift apart
* from the explanation that stops them being read as payouts.
*/
const COHORTS: Record<string, { label: string; meaning: string }> = {
infrastructure: {
label: 'Aggregator rebates',
meaning: 'credited in more than a fifth of every batch anyone submitted',
},
miner_fee: {
label: 'Miner fee shares',
meaning: 'paid to whichever block author included the batch',
},
paid_once: { label: 'Paid once', meaning: 'a single credit, ever' },
paid_a_few: { label: 'Paid 24 times', meaning: '' },
paid_often: { label: 'Paid 519 times', meaning: '' },
paid_regularly: {
label: 'Paid 20+ times',
meaning: 'a recurring payee — the closest thing to a payroll the chain shows',
},
}
/** Whether a class is the protocol's own plumbing rather than somebody's payee. */
function isMachinery(kind: string): boolean {
return kind === 'infrastructure' || kind === 'miner_fee'
}
export function WormholePanel({
chain,
decimals,
@@ -92,6 +122,41 @@ export function WormholePanel({
.filter((r) => !r.sender_hidden)
.reduce((sum, r) => sum + r.transfers, 0)
// What share of everything the exits pay is the protocol paying itself. The
// headline of the cohort table, because without it the bands below read as a
// pool ledger. BigInt throughout: these are token amounts, and a Number would
// lose precision well before the total does.
const machineryShare = (() => {
let total = 0n
let machinery = 0n
for (const c of data.cohorts) {
let amount: bigint
try {
amount = BigInt(c.amount)
} catch {
return null
}
total += amount
if (isMachinery(c.kind)) machinery += amount
}
if (total === 0n) return null
return Number((machinery * 1000n) / total) / 10
})()
// Mean credit, computed from the summed amount rather than sent per-row: the
// difference between the machinery's average and a payee's is most of what
// separates them, and rounding it away would flatten the table.
const each = (amount: string, credits: number): string => {
if (credits === 0) return '—'
let total: bigint
try {
total = BigInt(amount)
} catch {
return '—'
}
return tokens((total / BigInt(credits)).toString(), decimals, 3)
}
return (
<>
<section className="panel" style={{ marginBottom: 26 }}>
@@ -212,6 +277,79 @@ export function WormholePanel({
behaving correctly rather than the address behaving suspiciously.
</p>
</section>
<section className="panel" style={{ marginBottom: 26 }}>
<div className="panel-head">
<h2 className="panel-title">Who the exits pay</h2>
<span className="eyebrow">
public batches only · amounts in {symbol}
{machineryShare !== null && ` · ${machineryShare}% is plumbing`}
</span>
</div>
<div className="scroll-x">
<table className="board">
<caption className="visually-hidden">
Credits out of the wormhole, grouped by what each address appears to be
</caption>
<thead>
<tr>
<th scope="col" className="left">
Who
</th>
<th scope="col">Addresses</th>
<th scope="col">Credits</th>
<th scope="col">Amount</th>
<th scope="col">Each</th>
</tr>
</thead>
<tbody>
{data.cohorts.map((c) => {
const meta = COHORTS[c.kind]
return (
<tr key={c.kind}>
<td className="left">
<div className="call-cell">
<span>{meta?.label ?? c.kind}</span>
{meta?.meaning ? <span className="runtime-doc">{meta.meaning}</span> : null}
</div>
</td>
<td className="numeral">{fmtHeight(c.addresses)}</td>
<td className="numeral">{fmtHeight(c.credits)}</td>
<td className="numeral">{tokens(c.amount, decimals, 1)}</td>
<td className="numeral">{each(c.amount, c.credits)}</td>
</tr>
)
})}
</tbody>
</table>
</div>
<p className="panel-note">
<strong>
Most of what leaves the wormhole is the protocol paying itself
{machineryShare !== null && `, ${machineryShare}% of it`}.
</strong>{' '}
An aggregator&apos;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&apos;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&apos;s payout.
</p>
<p className="panel-note">
Batch <em>size</em> 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.
</p>
<p className="panel-note">
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.{' '}
<strong>That is a recurring-payment shape, and no more than that.</strong> 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.
</p>
</section>
</>
)
}