feat(ui): show where the mining rewards went
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Jp6a8EDar9ueEhAxzep4V5
This commit is contained in:
39
CLAUDE.md
39
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
|
||||
|
||||
@@ -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<NetworkSummary> {
|
||||
return get<NetworkSummary>(`/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<Distribution> {
|
||||
return get<Distribution>(`/chains/${chain}/distribution?window=${window}`, signal)
|
||||
}
|
||||
|
||||
151
web/src/components/DistributionPanel.tsx
Normal file
151
web/src/components/DistributionPanel.tsx
Normal file
@@ -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<Distribution | null>(null)
|
||||
const [error, setError] = useState<string | null>(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 <p className="empty">{error}</p>
|
||||
if (!data) return <p className="empty">Following the rewards…</p>
|
||||
|
||||
const amount = (raw: string | null | undefined) =>
|
||||
raw === null || raw === undefined ? '—' : tokens(raw, decimals, 1)
|
||||
|
||||
return (
|
||||
<section className="panel" style={{ marginBottom: 26 }}>
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Where the rewards went</h2>
|
||||
<span className="eyebrow">
|
||||
{fmtHeight(data.window_blocks)}-block window · 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">
|
||||
Each miner's share of blocks, what it was paid, and what it has sent on
|
||||
</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="left">
|
||||
Miner
|
||||
</th>
|
||||
<th scope="col">Share</th>
|
||||
<th scope="col">Mined</th>
|
||||
<th scope="col">Holds</th>
|
||||
<th scope="col">Sent on</th>
|
||||
<th scope="col">Recipients</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.miners.map((m) => {
|
||||
const recipients = m.flows?.recipients ?? null
|
||||
return (
|
||||
<tr key={m.miner}>
|
||||
<td className="left">
|
||||
<div className="call-cell">
|
||||
<Link to={href({ chain, miner: m.miner, window: windowName })}>
|
||||
{m.display}
|
||||
</Link>
|
||||
{/* 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' && (
|
||||
<span className="runtime-type">{shortAddress(m.address)}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="numeral">{fmtShare(m.share)}</td>
|
||||
<td className="numeral">{amount(m.mined)}</td>
|
||||
<td className="numeral">{amount(m.balance)}</td>
|
||||
<td className="numeral">{amount(m.flows?.sent)}</td>
|
||||
<td className="numeral">{recipients === null ? '—' : fmtHeight(recipients)}</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="panel-note">
|
||||
Every figure here is something the chain recorded: direction comes from each transfer
|
||||
event's own <code>from</code> and <code>to</code>, and what a miner mined is what the
|
||||
chain paid it.{' '}
|
||||
<strong>
|
||||
What the figures mean is a separate question, and this table does not answer it.
|
||||
</strong>{' '}
|
||||
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{' '}
|
||||
<em>recipients</em> is not an accusation; it is a starting point for one.
|
||||
</p>
|
||||
<p className="panel-note">
|
||||
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. <strong>Two of these columns are not measured the same way:</strong> <em>holds</em> is
|
||||
read from chain state and is true right now, while <em>mined</em>, <em>sent on</em> and{' '}
|
||||
<em>recipients</em> 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.{' '}
|
||||
<code>Sent on</code> also counts only transfers <em>out of the address the chain pays</em>:
|
||||
an operator distributing from a different wallet shows nothing here.
|
||||
</p>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -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({
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* 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. */}
|
||||
<DistributionPanel chain={chain} window="100800" decimals={decimals} symbol={symbol} />
|
||||
|
||||
<section className="panel" style={{ marginBottom: 26 }}>
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Activity</h2>
|
||||
|
||||
Reference in New Issue
Block a user