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

{error}

+ if (!data) return

Following the rewards…

+ + const amount = (raw: string | null | undefined) => + raw === null || raw === undefined ? '—' : tokens(raw, decimals, 1) + + return ( +
+
+

Where the rewards went

+ + {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'} + +
+ +
+ + + + + + + + + + + + + + {data.miners.map((m) => { + const recipients = m.flows?.recipients ?? null + return ( + + + + + + + + + ) + })} + +
+ Each miner's share of blocks, what it was paid, and what it has sent on +
+ Miner + ShareMinedHoldsSent onRecipients
+
+ + {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)} + )} +
+
{fmtShare(m.share)}{amount(m.mined)}{amount(m.balance)}{amount(m.flows?.sent)}{recipients === null ? '—' : fmtHeight(recipients)}
+
+ +

+ 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. */} + +

Activity