feat: a vesting route — when 99.5% of the supply actually arrives
The network page said 5,669,940 QTC sits in the vesting pot and stopped there, which is the largest economic fact about this chain rendered as a single number. `/:chain/vesting` now carries the schedules, the release curve and the evidence. It is the only forward-looking page on the site and it is exact rather than projected: the runtime vests nothing before a grant's cliff, the whole grant from its end, and linearly between, so summing that across the schedules is arithmetic on chain state. `blackbeard-core::vesting` restates those three branches with tests, because until 2027 nothing on chain will contradict them — covering the cliff, the mid-window, the `end` branch that makes the final figure exactly `total` rather than `total` less a rounding crumb, the `start < cliff` case that unlocks a chunk at once, and a grant large enough that `total × elapsed` would wrap a `u128` if the product were not split. Decoded by field name against the metadata, never by offset. The figures that justified building this were read positionally out of 89 bytes and summed to the pot exactly — good evidence the layout was guessed right, and exactly the shortcut this repository forbids elsewhere. A captured mainnet entry now pins the names, so a reordered struct fails the build rather than quietly paying the wrong address. It also retires a weak claim. "Held in the vesting pot" rested on an eight-byte `PalletId` spelling `qvesting` and a `type PalletId` line read from a source tree that may not be the deployed commit. `matches_pot` replaces that with arithmetic: the grants sum to the pot's balance to within the existential deposit, and if a grant is ended or the pot topped up the page says the two have parted company rather than going on describing one as the other. Measured: 48 grants, 5,669,940.000 QTC scheduled against 5,669,940.001 held, nothing claimed, first unlock 2027-09-09, fully vested 2030-09-08. `Field` becomes one component instead of four. Going to add a fourth copy found that the three had already parted company — two rendered `note` as a visible caption, the third passed it to `title` as a hover — so the shared one takes both as separate props rather than silently changing a page. The same lesson `isStandings` records, found the same way. Closes #21 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* One labelled figure in a stat grid.
|
||||
*
|
||||
* Extracted after it had been written out three times and the copies had
|
||||
* quietly parted company: two rendered `note` as a visible caption line, and
|
||||
* the third passed it to `title` as a hover tooltip. Both are wanted —
|
||||
* `NetworkPanel` captions a figure ("issuance less the endowment"), while
|
||||
* `NodePanel` needs the untruncated form of a value behind a hover — so this
|
||||
* takes them as separate props rather than picking a winner and silently
|
||||
* changing one of the pages.
|
||||
*
|
||||
* The lesson is the one `isStandings` already records: a thing written out four
|
||||
* times is four things, and they diverge without anybody deciding to.
|
||||
*/
|
||||
export function Field({
|
||||
label,
|
||||
value,
|
||||
note,
|
||||
title,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
/** A caption under the figure. Visible. */
|
||||
note?: string | undefined
|
||||
/** The full text behind a hover, where the figure is abbreviated. */
|
||||
title?: string | undefined
|
||||
}) {
|
||||
return (
|
||||
<div className="node-field">
|
||||
<div className="eyebrow">{label}</div>
|
||||
<div className="node-value" title={title}>
|
||||
{value}
|
||||
</div>
|
||||
{note && <div className="stat-note">{note}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -23,24 +23,7 @@ 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({
|
||||
label,
|
||||
value,
|
||||
note,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
note?: string | undefined
|
||||
}) {
|
||||
return (
|
||||
<div className="node-field">
|
||||
<div className="eyebrow">{label}</div>
|
||||
<div className="node-value">{value}</div>
|
||||
{note && <div className="stat-note">{note}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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". */
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Link } from 'react-router-dom'
|
||||
|
||||
import { ago, bandwidth, bytes, since } from '../lib/format'
|
||||
import { href } from '../lib/routes'
|
||||
import { Field } from './Field'
|
||||
|
||||
/** A two-letter country code as its flag, by offsetting into the regional
|
||||
* indicator block. Returns null for anything that is not two letters, so a
|
||||
@@ -32,25 +33,6 @@ function flag(code: string | null): string | null {
|
||||
return String.fromCodePoint(...[...code.toUpperCase()].map((c) => base + c.charCodeAt(0)))
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
note,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
note?: string | undefined
|
||||
}) {
|
||||
return (
|
||||
<div className="node-field">
|
||||
<div className="eyebrow">{label}</div>
|
||||
<div className="node-value" title={note}>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NodePanel({
|
||||
node,
|
||||
confidence,
|
||||
@@ -108,8 +90,10 @@ export function NodePanel({
|
||||
{node.version && <Field label="Version" value={node.version} />}
|
||||
{node.implementation && <Field label="Client" value={node.implementation} />}
|
||||
{os && <Field label="Target" value={os} />}
|
||||
{country && <Field label="Country" value={country} note={node.country_name ?? undefined} />}
|
||||
{node.cpu && <Field label="CPU" value={node.cpu} note={node.cpu} />}
|
||||
{country && (
|
||||
<Field label="Country" value={country} title={node.country_name ?? undefined} />
|
||||
)}
|
||||
{node.cpu && <Field label="CPU" value={node.cpu} title={node.cpu} />}
|
||||
{node.core_count !== null && <Field label="Cores" value={String(node.core_count)} />}
|
||||
{node.memory !== null && <Field label="Memory" value={bytes(node.memory)} />}
|
||||
{node.linux_distro && <Field label="Distribution" value={node.linux_distro} />}
|
||||
@@ -121,7 +105,7 @@ export function NodePanel({
|
||||
<Field
|
||||
label="Node uptime"
|
||||
value={since(node.startup_time)}
|
||||
note={`Started ${new Date(node.startup_time).toISOString()}`}
|
||||
title={`Started ${new Date(node.startup_time).toISOString()}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* When 99.5% of the supply actually arrives.
|
||||
*
|
||||
* The only forward-looking page on this site, and exact rather than projected:
|
||||
* the runtime vests nothing before a grant's cliff, the whole grant from its
|
||||
* end, and linearly between, so summing that across the schedules is
|
||||
* arithmetic on chain state rather than a forecast.
|
||||
*
|
||||
* It also carries the evidence for a claim the network page makes. "Held in
|
||||
* the vesting pot" rests on an eight-byte `PalletId` spelling `qvesting`; here
|
||||
* the grants are shown summing to that account's balance, which is an
|
||||
* association no naming convention is doing any work in.
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
import type { VestingSummary } from '../api/generated/VestingSummary'
|
||||
import { RequestFailed, fetchVesting } from '../api/rest'
|
||||
import { height as fmtHeight, shortAddress, tokens } from '../lib/format'
|
||||
import { href } from '../lib/routes'
|
||||
import { Field } from './Field'
|
||||
|
||||
const HEIGHT = 220
|
||||
const PAD = { top: 18, right: 20, bottom: 30, left: 62 }
|
||||
const FALLBACK_WIDTH = 720
|
||||
|
||||
function useMeasuredWidth(): [React.RefObject<HTMLDivElement | null>, number] {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
const [width, setWidth] = useState(FALLBACK_WIDTH)
|
||||
useEffect(() => {
|
||||
const node = ref.current
|
||||
if (!node) return
|
||||
const observe = new ResizeObserver(([entry]) => {
|
||||
if (entry) setWidth(Math.max(320, entry.contentRect.width))
|
||||
})
|
||||
observe.observe(node)
|
||||
return () => observe.disconnect()
|
||||
}, [])
|
||||
return [ref, width]
|
||||
}
|
||||
|
||||
const day = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
const month = (iso: string) =>
|
||||
new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short' })
|
||||
|
||||
/**
|
||||
* A raw amount as whole tokens, for geometry only.
|
||||
*
|
||||
* Every figure a reader sees goes through `tokens`, which is exact. A chain
|
||||
* amount is a 128-bit integer and `Number` loses it above 2^53, so this decides
|
||||
* how tall to draw a line and nothing else.
|
||||
*/
|
||||
const scaled = (raw: string, decimals: number) => {
|
||||
const n = Number(raw)
|
||||
return Number.isFinite(n) ? n / 10 ** decimals : 0
|
||||
}
|
||||
|
||||
function ReleaseCurve({
|
||||
data,
|
||||
decimals,
|
||||
symbol,
|
||||
}: {
|
||||
data: VestingSummary
|
||||
decimals: number
|
||||
symbol: string
|
||||
}) {
|
||||
const [wrap, width] = useMeasuredWidth()
|
||||
const points = data.release
|
||||
if (points.length < 2) return null
|
||||
|
||||
const times = points.map((p) => new Date(p.at).getTime())
|
||||
const t0 = Math.min(...times)
|
||||
const t1 = Math.max(...times)
|
||||
const span = t1 - t0 || 1
|
||||
const peak = Math.max(...points.map((p) => scaled(p.vested, decimals)), 1)
|
||||
const top = peak * 1.08
|
||||
|
||||
const plotW = width - PAD.left - PAD.right
|
||||
const plotH = HEIGHT - PAD.top - PAD.bottom
|
||||
const baseline = PAD.top + plotH
|
||||
const x = (t: number) => PAD.left + ((t - t0) / span) * plotW
|
||||
const y = (v: number) => baseline - (v / top) * plotH
|
||||
|
||||
const placed = points.map((p, i) => ({
|
||||
x: x(times[i] ?? t0),
|
||||
y: y(scaled(p.vested, decimals)),
|
||||
}))
|
||||
const line = placed.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)} ${p.y.toFixed(1)}`)
|
||||
const first = placed[0]!
|
||||
const last = placed[placed.length - 1]!
|
||||
const area = `${line.join(' ')} L${last.x.toFixed(1)} ${baseline} L${first.x.toFixed(1)} ${baseline} Z`
|
||||
|
||||
const nowX = x(new Date(data.computed_at).getTime())
|
||||
const yTicks = [0, top / 2, top].map((v) => ({ v, y: y(v) }))
|
||||
const xTicks = [0, 0.5, 1].map((f) => ({ t: t0 + span * f, x: x(t0 + span * f) }))
|
||||
|
||||
return (
|
||||
<div ref={wrap}>
|
||||
<svg
|
||||
className="chart"
|
||||
width={width}
|
||||
height={HEIGHT}
|
||||
viewBox={`0 0 ${width} ${HEIGHT}`}
|
||||
role="img"
|
||||
aria-label={`Cumulative vested supply from ${day(points[0]!.at)} to ${day(
|
||||
points[points.length - 1]!.at,
|
||||
)}, reaching ${tokens(data.total_scheduled, decimals, 0)} ${symbol}.`}
|
||||
>
|
||||
{yTicks.map((t) => (
|
||||
<g key={t.v}>
|
||||
<line className="grid" x1={PAD.left} x2={width - PAD.right} y1={t.y} y2={t.y} />
|
||||
<text className="chart-axis" x={PAD.left - 8} y={t.y + 3.5} textAnchor="end">
|
||||
{t.v === 0 ? '0' : `${Math.round(t.v / 1000)}k`}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{xTicks.map((t, i) => (
|
||||
<text
|
||||
key={t.x}
|
||||
className="chart-axis"
|
||||
x={t.x}
|
||||
y={HEIGHT - 8}
|
||||
textAnchor={i === 0 ? 'start' : i === 2 ? 'end' : 'middle'}
|
||||
>
|
||||
{month(new Date(t.t).toISOString())}
|
||||
</text>
|
||||
))}
|
||||
|
||||
<path className="area" d={area} />
|
||||
<path className="line" d={line.join(' ')} />
|
||||
|
||||
{/* Where the reader is standing. On this chain that is flat against the
|
||||
floor a year before anything unlocks, which is the single most
|
||||
useful thing the curve has to say. */}
|
||||
{nowX >= PAD.left && nowX <= width - PAD.right && (
|
||||
<>
|
||||
<line className="crosshair" x1={nowX} x2={nowX} y1={PAD.top} y2={baseline} />
|
||||
<text
|
||||
className="chart-axis"
|
||||
x={nowX + 5}
|
||||
y={PAD.top + 9}
|
||||
style={{ fill: 'var(--data-bright)' }}
|
||||
>
|
||||
now
|
||||
</text>
|
||||
</>
|
||||
)}
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function VestingPanel({
|
||||
chain,
|
||||
decimals,
|
||||
symbol,
|
||||
}: {
|
||||
chain: string
|
||||
decimals: number
|
||||
symbol: string
|
||||
}) {
|
||||
const [data, setData] = useState<VestingSummary | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController()
|
||||
setData(null)
|
||||
setError(null)
|
||||
fetchVesting(chain, 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])
|
||||
|
||||
if (error) return <p className="empty">{error}</p>
|
||||
if (!data) return <p className="empty">Reading the schedules…</p>
|
||||
if (data.schedules.length === 0) {
|
||||
return <p className="empty">This chain has no vesting schedules.</p>
|
||||
}
|
||||
|
||||
const amount = (raw: string) => `${tokens(raw, decimals)} ${symbol}`
|
||||
const outstanding = (() => {
|
||||
try {
|
||||
return (BigInt(data.total_scheduled) - BigInt(data.total_claimed)).toString()
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
})()
|
||||
const firstCliff = data.schedules.reduce(
|
||||
(a, s) => (a === null || s.cliff < a ? s.cliff : a),
|
||||
null as string | null,
|
||||
)
|
||||
const lastEnd = data.schedules.reduce(
|
||||
(a, s) => (a === null || s.end > a ? s.end : a),
|
||||
null as string | null,
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="panel" style={{ marginBottom: 26 }}>
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Vesting</h2>
|
||||
<span className="eyebrow">read from chain state</span>
|
||||
</div>
|
||||
<div className="node-grid">
|
||||
<Field label="Scheduled" value={amount(data.total_scheduled)} note="across every grant" />
|
||||
<Field label="Claimed" value={amount(data.total_claimed)} note="taken so far" />
|
||||
<Field
|
||||
label="Still to come"
|
||||
value={outstanding === null ? '—' : amount(outstanding)}
|
||||
note="scheduled less claimed"
|
||||
/>
|
||||
<Field label="Grants" value={fmtHeight(data.schedules.length)} />
|
||||
<Field
|
||||
label="First claimable"
|
||||
value={firstCliff === null ? '—' : day(firstCliff)}
|
||||
note="nothing unlocks before this"
|
||||
/>
|
||||
<Field label="Fully vested" value={lastEnd === null ? '—' : day(lastEnd)} />
|
||||
</div>
|
||||
<p className="panel-note">
|
||||
{data.matches_pot ? (
|
||||
<>
|
||||
<strong>The grants account for the pot exactly.</strong> They sum to{' '}
|
||||
{amount(data.total_scheduled)} against the{' '}
|
||||
{data.pot?.balance ? amount(data.pot.balance) : 'balance'} held at{' '}
|
||||
{data.pot ? <code>{shortAddress(data.pot.address)}</code> : 'the pallet account'} —
|
||||
the difference is the existential deposit, the minimum that keeps the account from
|
||||
being reaped. That match is why the network page can call it a vesting pot: the
|
||||
association is arithmetic, not an inference from the eight bytes of{' '}
|
||||
<code>PalletId</code> that happen to spell <code>qvesting</code>.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<strong>The grants no longer account for the pot.</strong> They sum to{' '}
|
||||
{amount(data.total_scheduled)} against{' '}
|
||||
{data.pot?.balance ? amount(data.pot.balance) : 'an unknown balance'}, a difference of{' '}
|
||||
{amount(data.pot_shortfall)}. A grant ended or the pot topped up would both do this;
|
||||
until it is understood, treat the two as separate facts.
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="panel" style={{ marginBottom: 26 }}>
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">When it arrives</h2>
|
||||
<span className="eyebrow">cumulative, in {symbol}</span>
|
||||
</div>
|
||||
<ReleaseCurve data={data} decimals={decimals} symbol={symbol} />
|
||||
<p className="panel-note">
|
||||
Exact rather than projected. The runtime vests nothing before a grant's cliff, the
|
||||
whole grant from its end, and linearly in between — so this is that arithmetic summed
|
||||
across every schedule, not a model of what somebody intends.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="panel" style={{ marginBottom: 26 }}>
|
||||
<div className="panel-head">
|
||||
<h2 className="panel-title">Every grant</h2>
|
||||
<span className="eyebrow">largest first · amounts in {symbol}</span>
|
||||
</div>
|
||||
<div className="scroll-x">
|
||||
<table className="board">
|
||||
<caption className="visually-hidden">Vesting grants, largest first</caption>
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" className="left">
|
||||
Beneficiary
|
||||
</th>
|
||||
<th scope="col">Grant</th>
|
||||
<th scope="col">Vested</th>
|
||||
<th scope="col">Claimed</th>
|
||||
<th scope="col" className="left">
|
||||
Unlocks
|
||||
</th>
|
||||
<th scope="col" className="left">
|
||||
Fully vested
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.schedules.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td className="left">
|
||||
<Link to={href({ chain, account: s.beneficiary })} title={s.beneficiary}>
|
||||
{shortAddress(s.beneficiary)}
|
||||
</Link>
|
||||
</td>
|
||||
<td className="numeral">{tokens(s.total, decimals, 0)}</td>
|
||||
<td className="numeral">{tokens(s.vested, decimals, 0)}</td>
|
||||
<td className="numeral">{tokens(s.claimed, decimals, 0)}</td>
|
||||
<td className="left">{day(s.cliff)}</td>
|
||||
<td className="left">{day(s.end)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="panel-note">
|
||||
<strong>A snapshot, never a promise.</strong> <code>Vesting::retarget_schedule</code> can
|
||||
change who a grant pays and <code>end_schedule</code> can end one, so every row describes
|
||||
what the chain says today rather than what anybody is owed.
|
||||
</p>
|
||||
<p className="panel-note">
|
||||
The beneficiaries are addresses and nothing more. Nothing on this chain calls anyone a
|
||||
founder, a team or an investor, so neither does this table — the only thing asserted here
|
||||
is which address a grant is currently pointed at.
|
||||
</p>
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -20,24 +20,7 @@ import type { WormholeSummary } from '../api/generated/WormholeSummary'
|
||||
import { FlowChart } from './FlowChart'
|
||||
import { RequestFailed, fetchWormhole } from '../api/rest'
|
||||
import { height as fmtHeight, tokens } from '../lib/format'
|
||||
|
||||
function Field({
|
||||
label,
|
||||
value,
|
||||
note,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
note?: string | undefined
|
||||
}) {
|
||||
return (
|
||||
<div className="node-field">
|
||||
<div className="eyebrow">{label}</div>
|
||||
<div className="node-value">{value}</div>
|
||||
{note && <div className="stat-note">{note}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { Field } from './Field'
|
||||
|
||||
/** The route's own name, since block initialisation has no extrinsic. */
|
||||
function routeName(produced_by: string): string {
|
||||
|
||||
Reference in New Issue
Block a user