From df2306660a422cc55e4801269b153ca494ab8704 Mon Sep 17 00:00:00 2001
From: rob thijssen
Date: Mon, 14 Sep 2026 09:03:48 +0300
Subject: [PATCH] feat(ui): show where the mining rewards went
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A table on the network page: each miner's share of blocks, what the chain paid
it, what it holds, what it has sent on, and to how many distinct addresses.
Built to answer a concern without ever being the thing that makes the
accusation. There is no badge, no flag and no word like "delinquent" anywhere in
it. What earns it its place is comparison — one miner's recipient count says
almost nothing, and beside its neighbours' it says a great deal — so the reader
draws the conclusion and the site lays out the evidence and names its limits.
Three limits are on the page, not in a footnote:
- an operator may hold several addresses, settle off-chain, or be a solo miner
keeping what it earned, which is the ordinary case and unremarkable
- `sent on` counts only transfers out of the address the chain pays, so an
operator distributing from a different wallet shows nothing
- the name column is the weakest and comes from telemetry rather than the chain.
There is no such thing as a "pool" here: the word appears only inside a name
an operator chose for its own node
`holds` is chain state and `mined`/`sent on`/`recipients` are over the indexed
range, which the note spells out — on a partially indexed chain a miner appears
to hold far more than it ever mined, and that discrepancy looks exactly like the
thing the table is for.
The window is fixed at 100,800 blocks rather than carried from the route: the
window selector is hidden on this page, so the route's value would mean the
shares silently reflect whatever span the reader last chose on the standings,
invisibly and differently for two people reading the same page. The longest
window is also the right one for the question — what a miner did with its
rewards is about the chain's history, not its last hour.
Refs #17
Co-Authored-By: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
---
CLAUDE.md | 39 ++++++
web/src/api/rest.ts | 10 ++
web/src/components/DistributionPanel.tsx | 151 +++++++++++++++++++++++
web/src/components/NetworkPanel.tsx | 9 ++
4 files changed, 209 insertions(+)
create mode 100644 web/src/components/DistributionPanel.tsx
diff --git a/CLAUDE.md b/CLAUDE.md
index e501846..c457c08 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -538,6 +538,45 @@ Migrations are sequentially versioned and **immutable once committed**. Correct
mistake with a new file, never by editing one that has landed — the runner's
checksum diverges and it refuses to start.
+## The distribution table reports flows and does not judge them
+
+`/:chain/network` carries a table of who won the blocks and what became of the
+rewards. It exists because a concern was raised that a pool might not be paying
+out, and it is built so that it can never be the thing making that accusation.
+
+**Direction is a fact in the payload.** `Wormhole::NativeTransferred` and
+`Balances::Transfer` both carry `from`, `to` and `amount`, so inflow and outflow
+are counted from what the chain recorded rather than inferred from an account
+appearing near an event.
+
+**A nonce cannot answer this, and reaching for one is the obvious mistake.**
+Wormhole transfers dispatch *unsigned* — `verify_private_batch` and
+`verify_public_batch` have no signer — so an address here can move a fortune
+with `nonce: 0`. Reading a zero nonce as "has never sent anything" is wrong on
+this chain.
+
+**What the numbers cannot settle**, and what the page therefore says out loud:
+an operator may hold several addresses; may settle off-chain; or may be a solo
+miner keeping what it earned, which is the ordinary case. Measured on mainnet,
+several large accounts send nothing at all, and the accounts distributing to
+hundreds of recipients have never mined a block — so payout wallets here are
+funded from somewhere other than mining addresses. There is also no such thing
+as a "pool" on this chain: the word appears only inside a name an operator
+reports over telemetry, which is why `MinerFlow` carries the `AttributionSource`
+and the table calls the name column its weakest. A site that flagged entities as
+delinquent on the strength of a name they chose themselves is one bad match away
+from libelling someone.
+
+What earns the table its place is **comparison**. One miner's recipient count
+says almost nothing; beside its neighbours' it says a great deal. The reader
+draws the conclusion; the site lays out the evidence and names its limits.
+
+**Two columns are not measured the same way.** `holds` is chain state, true now;
+`mined`, `sent on` and `recipients` are over the indexed range. On a chain read
+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 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/web/src/api/rest.ts b/web/src/api/rest.ts
index ab9cac4..c23ccdd 100644
--- a/web/src/api/rest.ts
+++ b/web/src/api/rest.ts
@@ -20,6 +20,7 @@ import type { GenesisDetail } from './generated/GenesisDetail'
import type { ReversibleState } from './generated/ReversibleState'
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 { NodeIndex } from './generated/NodeIndex'
import type { NodeInfo } from './generated/NodeInfo'
@@ -256,3 +257,12 @@ export async function fetchNode(
export async function fetchNetwork(chain: string, signal?: AbortSignal): Promise {
return get(`/chains/${chain}/network`, signal)
}
+
+/** Who won the blocks, and what became of the rewards. */
+export async function fetchDistribution(
+ chain: string,
+ window: WindowName,
+ signal?: AbortSignal,
+): Promise {
+ return get(`/chains/${chain}/distribution?window=${window}`, signal)
+}
diff --git a/web/src/components/DistributionPanel.tsx b/web/src/components/DistributionPanel.tsx
new file mode 100644
index 0000000..ef186b9
--- /dev/null
+++ b/web/src/components/DistributionPanel.tsx
@@ -0,0 +1,151 @@
+/**
+ * Who won the blocks, and what became of the rewards.
+ *
+ * **This table reports flows and does not judge them.** Every figure is
+ * something the chain recorded — direction comes from each transfer event's own
+ * `from` and `to` — but what the figures *mean* is a question the data cannot
+ * settle. An operator may hold several addresses, may settle off-chain, or may
+ * simply be a solo miner keeping what it earned, which is the ordinary case and
+ * unremarkable.
+ *
+ * So there is no badge, no flag, and no word like "delinquent" anywhere here.
+ * What earns the table its place is **comparison**: one miner's recipient count
+ * says almost nothing, and beside its neighbours' it says a great deal. The
+ * reader draws the conclusion; the site lays out the evidence and names its
+ * limits.
+ *
+ * The name column is the weakest thing on the page and is marked as such. There
+ * is no such thing as a "pool" on this chain — the word appears only inside a
+ * name an operator reports over telemetry, and a site that flagged entities as
+ * delinquent on the strength of a name they chose themselves would be one bad
+ * match away from libelling someone.
+ */
+
+import { useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+
+import type { Distribution } from '../api/generated/Distribution'
+import { RequestFailed, fetchDistribution } from '../api/rest'
+import { height as fmtHeight, share as fmtShare, shortAddress, tokens } from '../lib/format'
+import { href } from '../lib/routes'
+import type { Window as WindowName } from '../api/generated/Window'
+
+export function DistributionPanel({
+ chain,
+ window: windowName,
+ decimals,
+ symbol,
+}: {
+ chain: string
+ window: WindowName
+ decimals: number
+ /** The unit, shown once in the header rather than on every cell: six columns
+ * of amounts each repeating "QTC" is noise, and they are all the same unit. */
+ symbol: string
+}) {
+ const [data, setData] = useState(null)
+ const [error, setError] = useState(null)
+
+ useEffect(() => {
+ const controller = new AbortController()
+ setData(null)
+ setError(null)
+ fetchDistribution(chain, windowName, 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, windowName])
+
+ if (error) return
+
+ {m.display}
+
+ {/* Only where the name is not already the address: an
+ unattributed miner renders as its payout address, and
+ repeating it underneath is one string printed twice. */}
+ {m.address && m.attribution !== 'preimage' && (
+ {shortAddress(m.address)}
+ )}
+
+ Every figure here is something the chain recorded: direction comes from each transfer
+ event's own from and to, and what a miner mined is what the
+ chain paid it.{' '}
+
+ What the figures mean is a separate question, and this table does not answer it.
+ {' '}
+ An operator may hold several addresses, may settle off-chain, or may be a solo miner keeping
+ what it earned — which is the ordinary case and not remarkable. A zero in{' '}
+ recipients is not an accusation; it is a starting point for one.
+
+
+ The names are the weakest column and come from telemetry, not the chain — an operator
+ chooses what its node calls itself, so a name containing “pool” is a claim rather than a
+ fact. Two of these columns are not measured the same way:holds is
+ read from chain state and is true right now, while mined, sent on and{' '}
+ recipients are counted over the indexed range above. On a chain read from genesis
+ those agree; on one still being indexed backwards they do not, and a miner can appear to
+ hold far more than it ever mined simply because the earlier half has not been read yet.{' '}
+ Sent on also counts only transfers out of the address the chain pays:
+ an operator distributing from a different wallet shows nothing here.
+
+
+ )
+}
diff --git a/web/src/components/NetworkPanel.tsx b/web/src/components/NetworkPanel.tsx
index 494d5cb..1895355 100644
--- a/web/src/components/NetworkPanel.tsx
+++ b/web/src/components/NetworkPanel.tsx
@@ -21,6 +21,7 @@ import type { NetworkSummary } from '../api/generated/NetworkSummary'
import { RequestFailed, fetchNetwork } from '../api/rest'
import { height as fmtHeight, tokens } from '../lib/format'
import { href } from '../lib/routes'
+import { DistributionPanel } from './DistributionPanel'
import { Sparkline, type SparkPoint } from './Sparkline'
function Field({
@@ -193,6 +194,14 @@ export function NetworkPanel({
+ {/* A fixed window, not the route's. The window selector is hidden on this
+ page, so carrying the route's would mean the shares silently reflect
+ whatever span the reader last picked on the standings — invisible, and
+ different for two people looking at the same page. The longest window
+ is also the right one for the question: what a miner has done with its
+ rewards is a question about the chain's history, not its last hour. */}
+
+