The market index lists the venues a chain's token is followed on, asked of a
new /v1/chains/{chain}/markets so a chain followed nowhere says so and a
second venue needs no frontend change. The near-intents page carries the
price figures and listing evidence, swaps as they settle (from the
IntentsSwap push), and the settled history, paged newest first through
/intents/swaps?order=newest&before=… — the existing `after` stays for readers
going forward, and mixing the two directions is a 400.
`market` is a subject in lib/routes.ts like `node`: in parse, href and
isStandings together. `markets` parses too and is rewritten to the singular
every other kind uses.
Rendered against 964 real wNEAR trades before it shipped, which is what
showed the panel did not scale: seventy counter tokens became seventy tiles,
NFT swaps sat in the grid as prices with no number, and "wNEAR in USDC"
appeared six times for six different bridged tokens. Now only counters with
decimals are prices, most traded first — four tiles on the network page,
eight and a table of the rest on the market page — NFT and multi-token swaps
are a count, and a bridged token is labelled by its origin (`USDC · base`),
the contract it is, not the symbol it chose.
Closes #32
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015pkwBsR54NRmZyU4QVhvfj
388 lines
16 KiB
TypeScript
388 lines
16 KiB
TypeScript
/**
|
||
* What this economy is doing, in one place.
|
||
*
|
||
* Two kinds of number live on this page and they are not equally certain, which
|
||
* is why they are in separate panels rather than one grid of tiles.
|
||
*
|
||
* **Supply and the map sizes are chain state**, read now and true now. Every one
|
||
* is a value the runtime holds, not a figure derived by this observer.
|
||
*
|
||
* **The activity is our index**, and is only as complete as the range it prints
|
||
* beside itself. On a chain indexed from genesis that is the whole history; on a
|
||
* testnet still walking backwards it is not, and a daily chart whose axis claims
|
||
* a month while the data covers a week is precisely the failure this repository
|
||
* has already shipped twice.
|
||
*/
|
||
|
||
import { useEffect, useState } from 'react'
|
||
import { Link } from 'react-router-dom'
|
||
|
||
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 { IntentsPanel } from './IntentsPanel'
|
||
import { Sparkline, type SparkPoint } from './Sparkline'
|
||
import { Field } from './Field'
|
||
|
||
/** A count, or an em dash where the figure is genuinely unknown — never a zero
|
||
* standing in for "we could not read it". */
|
||
function count(value: number | null): string {
|
||
return value === null ? '—' : fmtHeight(value)
|
||
}
|
||
|
||
export function NetworkPanel({
|
||
chain,
|
||
decimals,
|
||
symbol,
|
||
}: {
|
||
chain: string
|
||
decimals: number
|
||
symbol: string
|
||
}) {
|
||
const [summary, setSummary] = useState<NetworkSummary | null>(null)
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
useEffect(() => {
|
||
const controller = new AbortController()
|
||
setSummary(null)
|
||
setError(null)
|
||
fetchNetwork(chain, controller.signal)
|
||
.then(setSummary)
|
||
.catch((e: unknown) => {
|
||
if (controller.signal.aborted) return
|
||
setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.')
|
||
})
|
||
return () => controller.abort()
|
||
}, [chain])
|
||
|
||
if (error) return <p className="empty">{error}</p>
|
||
if (!summary) return <p className="empty">Reading the chain…</p>
|
||
|
||
const amount = (raw: string | null) => (raw === null ? '—' : `${tokens(raw, decimals)} ${symbol}`)
|
||
const locks = summary.locks
|
||
const nothingLocked =
|
||
locks !== null &&
|
||
locks.locks === 0 &&
|
||
locks.holds === 0 &&
|
||
locks.freezes === 0 &&
|
||
locks.reserves === 0
|
||
|
||
// The vesting pot, which on this chain holds almost everything. Rendered as a
|
||
// percentage because two eighteen-digit strings do not compare at a glance,
|
||
// and 99.5% is the whole point.
|
||
// Only rendered when the account walk reached the end of the map: every share
|
||
// here divides by a total, and a partial sum would make all of them wrong
|
||
// while looking entirely reasonable.
|
||
const conc = summary.concentration
|
||
/** Parts per ten thousand as a percentage, or an em dash. */
|
||
const bps = (v: number | null) => (v === null ? '—' : `${(v / 100).toFixed(v > 9900 ? 2 : 1)}%`)
|
||
|
||
const pot = summary.vesting_pot
|
||
const potShare =
|
||
pot && pot.share_bps !== null
|
||
? `${(pot.share_bps / 100).toFixed(pot.share_bps > 9900 ? 2 : 1)}%`
|
||
: null
|
||
|
||
const spark = (pick: (d: NetworkSummary['days'][number]) => number, unit: string): SparkPoint[] =>
|
||
summary.days.map((d) => ({
|
||
value: pick(d),
|
||
label: `${fmtHeight(pick(d))} ${unit} · ${d.day.slice(0, 10)}`,
|
||
}))
|
||
|
||
// The indexed range against the chain's height, so the reader can see how
|
||
// much of the chain these daily figures actually cover.
|
||
const complete =
|
||
summary.indexed_from === 1 && summary.height !== null && summary.indexed_to !== null
|
||
? summary.indexed_to >= summary.height - 2
|
||
: false
|
||
|
||
return (
|
||
<>
|
||
<section className="panel" style={{ marginBottom: 26 }}>
|
||
<div className="panel-head">
|
||
<h2 className="panel-title">Supply</h2>
|
||
<span className="eyebrow">
|
||
read from chain state
|
||
{summary.spec_version !== null && ` · runtime v${summary.spec_version}`}
|
||
</span>
|
||
</div>
|
||
<div className="node-grid">
|
||
<Field label="Total issuance" value={amount(summary.total_issuance)} />
|
||
<Field
|
||
label="Endowed at genesis"
|
||
value={amount(summary.genesis_endowment)}
|
||
note="handed out in block zero"
|
||
/>
|
||
<Field
|
||
label="Mined since genesis"
|
||
value={amount(summary.mined_since_genesis)}
|
||
note="issuance less the endowment"
|
||
/>
|
||
<Field
|
||
label="Held in the vesting pot"
|
||
value={pot ? amount(pot.balance) : '—'}
|
||
note={
|
||
potShare === null
|
||
? 'the vesting pallet\u2019s own account'
|
||
: `${potShare} of issuance`
|
||
}
|
||
/>
|
||
<Field
|
||
label="Issuance outside the pot"
|
||
value={amount(summary.issuance_outside_pot)}
|
||
note="the ceiling on what has been distributed"
|
||
/>
|
||
<Field label="Fees collected" value={amount(summary.collected_fees)} />
|
||
<Field label="Inactive issuance" value={amount(summary.inactive_issuance)} />
|
||
<Field
|
||
label="Accounts"
|
||
value={count(summary.accounts)}
|
||
note={summary.accounts_complete ? 'counted in full' : 'count reached its ceiling'}
|
||
/>
|
||
</div>
|
||
<p className="panel-note">
|
||
{pot && potShare !== null && (
|
||
<>
|
||
<strong>{potShare} of all issuance sits in one account</strong> — {pot.address}, which
|
||
the vesting pallet owns and nobody holds a key to. This runtime vests from a pot
|
||
rather than with locks: undistributed funds wait there and are paid out from it.{' '}
|
||
</>
|
||
)}
|
||
{nothingLocked ? (
|
||
<>
|
||
All four of the balances pallet's immobilising maps — locks, holds, freezes and
|
||
reserves — are empty, counted rather than assumed.{' '}
|
||
<strong>That is not the same as nothing being withheld</strong>: this chain withholds
|
||
with the pot above, which no lock records. There is still no “circulating supply”
|
||
figure here, because funds leave the pot on a schedule this observer does not read and
|
||
an address outside it may be unspendable for other reasons — but issuance alone would
|
||
overstate what is in play by a factor of several hundred.{' '}
|
||
</>
|
||
) : (
|
||
locks && (
|
||
<>
|
||
Supply is immobilised in {count(locks.locks)} locks, {count(locks.holds)} holds,{' '}
|
||
{count(locks.freezes)} freezes and {count(locks.reserves)} reserves.{' '}
|
||
</>
|
||
)
|
||
)}
|
||
{summary.vesting_schedules !== null && summary.vesting_schedules > 0 && (
|
||
<>
|
||
The {count(summary.vesting_schedules)} vesting schedules are a count rather than a
|
||
sum; the sum that matters is the pot, and it is read directly.{' '}
|
||
</>
|
||
)}
|
||
<strong>Account balances are not added up anywhere on this site.</strong> On this chain
|
||
they exceed total issuance and the gap grows: a wormhole exit credits its destination
|
||
without debiting the note's source, so value that moves privately appears twice in
|
||
any such total. Total issuance is the figure that counts each unit once.
|
||
</p>
|
||
</section>
|
||
|
||
<IntentsPanel chain={chain} decimals={decimals} symbol={symbol} linkMarket />
|
||
|
||
{conc !== null && conc.complete && (
|
||
<section className="panel" style={{ marginBottom: 26 }}>
|
||
<div className="panel-head">
|
||
<h2 className="panel-title">Concentration</h2>
|
||
<span className="eyebrow">outside the vesting pot</span>
|
||
</div>
|
||
<div className="node-grid">
|
||
<Field
|
||
label="Accounts"
|
||
value={count(conc.accounts)}
|
||
note={`${count(conc.holders)} hold anything`}
|
||
/>
|
||
<Field
|
||
label="Largest holder"
|
||
value={bps(conc.top1_bps)}
|
||
note="of the balance outside the pot"
|
||
/>
|
||
<Field label="Largest ten" value={bps(conc.top10_bps)} />
|
||
<Field label="Largest hundred" value={bps(conc.top100_bps)} />
|
||
<Field
|
||
label="Gini"
|
||
value={conc.gini === null ? '—' : conc.gini.toFixed(3)}
|
||
note="0 even, 1 one holder"
|
||
/>
|
||
<Field
|
||
label="Outside the pot"
|
||
value={amount(conc.outside_pot)}
|
||
note="what the shares divide"
|
||
/>
|
||
</div>
|
||
<p className="panel-note">
|
||
<strong>The vesting pot is excluded from every figure above.</strong> It holds{' '}
|
||
{conc.pot?.share_bps === null || conc.pot === null
|
||
? 'almost all'
|
||
: bps(conc.pot.share_bps)}{' '}
|
||
of the chain's counted balance, and a concentration measure that included it would
|
||
describe an undistributed reserve — it would sit near 1.000 and move only when the pot
|
||
pays out. These figures divide by what is outside it.
|
||
</p>
|
||
<p className="panel-note">
|
||
The denominator is the <em>counted balance</em>, not total issuance. The two differ: a
|
||
wormhole exit credits its destination without debiting the note's source, so
|
||
balances summed across the chain exceed issuance by however much has ever been taken
|
||
out. Shares of issuance would add up to more than everything.
|
||
</p>
|
||
<p className="panel-note">
|
||
<strong>Read the tail carefully.</strong> A mining reward is paid to an address derived
|
||
from the miner's own preimage, which nothing can sign for — so those balances are
|
||
cumulative receipts that can only rise, and never fall however much their operator pays
|
||
out. Concentration across them is in part a statement about who has mined the most
|
||
rather than about who holds the most.
|
||
</p>
|
||
</section>
|
||
)}
|
||
|
||
<section className="panel" style={{ marginBottom: 26 }}>
|
||
<div className="panel-head">
|
||
<h2 className="panel-title">Capability</h2>
|
||
<span className="eyebrow">what is shipped, and what is used</span>
|
||
</div>
|
||
<div className="node-grid">
|
||
<Field
|
||
label="Calls used"
|
||
value={`${fmtHeight(summary.calls_used)} of ${fmtHeight(summary.calls_declared)}`}
|
||
note="dispatchables ever called"
|
||
/>
|
||
<Field
|
||
label="Events fired"
|
||
value={`${fmtHeight(summary.events_fired)} of ${fmtHeight(summary.events_declared)}`}
|
||
note="event kinds ever emitted"
|
||
/>
|
||
<Field label="Vesting schedules" value={count(summary.vesting_schedules)} />
|
||
<Field label="Referenda" value={count(summary.referenda)} note="ever opened" />
|
||
<Field
|
||
label="Reversible transfers"
|
||
value={count(summary.reversible_transfers)}
|
||
note="ever created"
|
||
/>
|
||
<Field
|
||
label="Wormhole leaves"
|
||
value={count(summary.wormhole_leaves)}
|
||
note="one per mining reward"
|
||
/>
|
||
</div>
|
||
<p className="panel-note">
|
||
A zero here is a finding, not a gap: a pallet shipped and never touched says something
|
||
about a chain that its absence would not. The usage counts are over this observer's
|
||
index rather than all of history —{' '}
|
||
<Link to={href({ chain, index: 'call' })}>the call index</Link> has the detail, including
|
||
every dispatchable nobody has ever used.
|
||
</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>
|
||
<span className="eyebrow">
|
||
{summary.indexed_from !== null && summary.indexed_to !== null
|
||
? `blocks ${fmtHeight(summary.indexed_from)}–${fmtHeight(summary.indexed_to)}${
|
||
complete ? ' · the whole chain' : ' · a partial index'
|
||
}`
|
||
: 'nothing indexed yet'}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="network-sparks">
|
||
{[
|
||
{
|
||
label: 'Signed extrinsics',
|
||
pick: (d: NetworkSummary['days'][number]) => d.signed,
|
||
unit: 'signed',
|
||
},
|
||
{
|
||
label: 'Distinct signers',
|
||
pick: (d: NetworkSummary['days'][number]) => d.signers,
|
||
unit: 'signers',
|
||
},
|
||
{
|
||
label: 'New accounts',
|
||
pick: (d: NetworkSummary['days'][number]) => d.new_accounts,
|
||
unit: 'accounts',
|
||
},
|
||
{
|
||
label: 'Blocks',
|
||
pick: (d: NetworkSummary['days'][number]) => d.blocks,
|
||
unit: 'blocks',
|
||
},
|
||
].map((s) => (
|
||
<div key={s.label} className="node-field">
|
||
<div className="eyebrow">{s.label}</div>
|
||
<div className="node-value">
|
||
{fmtHeight(s.pick(summary.days[summary.days.length - 1]!))}
|
||
</div>
|
||
{/* No hover readout here: each tile already prints its latest
|
||
value above the line, and five probe handlers competing over
|
||
one row of 200px tiles is a pile rather than a hover layer —
|
||
the same reason the headline tiles borrow their note line. */}
|
||
<Sparkline
|
||
points={spark(s.pick, s.unit)}
|
||
label={`${s.label} per day`}
|
||
onProbe={() => {}}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className="scroll-x">
|
||
<table className="board">
|
||
<caption className="visually-hidden">Activity per day, newest first</caption>
|
||
<thead>
|
||
<tr>
|
||
<th scope="col" className="left">
|
||
Day
|
||
</th>
|
||
<th scope="col">Blocks</th>
|
||
<th scope="col">Miners</th>
|
||
<th scope="col">Extrinsics</th>
|
||
<th scope="col">Signed</th>
|
||
<th scope="col">Signers</th>
|
||
<th scope="col">Events</th>
|
||
<th scope="col">New accounts</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{[...summary.days].reverse().map((d) => (
|
||
<tr key={d.day}>
|
||
<td className="left numeral">{d.day.slice(0, 10)}</td>
|
||
<td className="numeral">{fmtHeight(d.blocks)}</td>
|
||
<td className="numeral">{fmtHeight(d.miners)}</td>
|
||
<td className="numeral">{fmtHeight(d.extrinsics)}</td>
|
||
<td className="numeral">{fmtHeight(d.signed)}</td>
|
||
<td className="numeral">{fmtHeight(d.signers)}</td>
|
||
<td className="numeral">{fmtHeight(d.events)}</td>
|
||
<td className="numeral">{fmtHeight(d.new_accounts)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<p className="panel-note">
|
||
Bucketed by the chain's own clock, never by when this observer saw anything: a
|
||
stretch caught up on carries observation times minutes apart for blocks spanning days, and
|
||
a daily series built on that reports a fortnight as one afternoon.{' '}
|
||
<strong>Signed is separated from the total</strong> because most extrinsics on this chain
|
||
are inherents — the timestamp every block carries — so a single “transactions per day”
|
||
line would be mostly clockwork, a number that looks like adoption and is not.
|
||
{!complete &&
|
||
' These counts are of what has been read, not of what happened: this chain is still being indexed backwards.'}
|
||
</p>
|
||
</section>
|
||
</>
|
||
)
|
||
}
|