From 2bbe8e4d5da9f1e7d722c42b1f9ce8e77d4fdaec Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Mon, 14 Sep 2026 16:31:53 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20a=20vesting=20route=20=E2=80=94=20when?= =?UTF-8?q?=2099.5%=20of=20the=20supply=20actually=20arrives?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CLAUDE.md | 35 ++ crates/blackbeard-api/src/routes.rs | 275 ++++++++++++++- crates/blackbeard-api/src/state.rs | 4 + crates/blackbeard-core/src/lib.rs | 1 + crates/blackbeard-core/src/runtime.rs | 71 ++++ crates/blackbeard-core/src/vesting.rs | 191 +++++++++++ .../tests/mainnet-vesting-key.hex | 1 + .../tests/mainnet-vesting-schedule.hex | 1 + crates/blackbeard-entities/src/chain.rs | 81 +++++ crates/blackbeard-entities/src/lib.rs | 3 +- web/src/App.tsx | 8 + web/src/api/generated/VestingPoint.ts | 15 + web/src/api/generated/VestingSchedule.ts | 52 +++ web/src/api/generated/VestingSummary.ts | 62 ++++ web/src/api/rest.ts | 6 + web/src/components/Field.tsx | 37 ++ web/src/components/NetworkPanel.tsx | 19 +- web/src/components/NodePanel.tsx | 28 +- web/src/components/VestingPanel.tsx | 318 ++++++++++++++++++ web/src/components/WormholePanel.tsx | 19 +- web/src/lib/routes.ts | 4 + 21 files changed, 1169 insertions(+), 62 deletions(-) create mode 100644 crates/blackbeard-core/src/vesting.rs create mode 100644 crates/blackbeard-core/tests/mainnet-vesting-key.hex create mode 100644 crates/blackbeard-core/tests/mainnet-vesting-schedule.hex create mode 100644 web/src/api/generated/VestingPoint.ts create mode 100644 web/src/api/generated/VestingSchedule.ts create mode 100644 web/src/api/generated/VestingSummary.ts create mode 100644 web/src/components/Field.tsx create mode 100644 web/src/components/VestingPanel.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 2dc6163..c1a62b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -756,6 +756,41 @@ Three specifics worth keeping: never on when this observer saw anything. A stretch caught up on carries observation times minutes apart for blocks spanning days. +## The vesting route is the only forward-looking page, and it is exact + +`/:chain/vesting` sums `pallet_vesting`'s own accrual across every schedule: +nothing before a grant's cliff, the whole grant from its `end`, linear between. +That is arithmetic on chain state, not a projection — which is the only reason a +site this careful about measurement will draw a curve into 2030 at all. + +`blackbeard_core::vesting` restates the runtime's three branches and is the +half that can be tested, because until 2027 nothing on chain will contradict it: +the `end` branch returning `total` exactly rather than letting the linear case +run out is what stops the final figure being `total` less a rounding crumb, and +`mul_div_floor` splits the product because `total × elapsed` on a large grant +passes what a `u128` holds and would wrap into something small and believable. + +**Decoded by field name, 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 precisely the shortcut this file +already forbids elsewhere. A runtime that reordered the struct would keep +decoding, keep summing to something, and be wrong about who gets paid what. +`a_vesting_schedule_decodes_by_name_against_real_metadata` pins the names +against a captured mainnet entry. + +**`matches_pot` is reported, not assumed.** The network page calls that account +a vesting pot; what earns the word is that the grants sum to its balance to +within `ExistentialDeposit`. If a grant is ever ended or the pot topped up, the +page says the two have parted company rather than going on describing one as the +other. Measured 2026-09-14: 48 grants, 5,669,940.000 QTC scheduled against +5,669,940.001 held, nothing claimed, cliff 2027-09-09, fully vested 2030-09-08. + +**`Field` is one component, not four.** It had been written out three times and +the copies had parted company — two rendered `note` as a visible caption, the +third passed it to `title` as a hover. Both are wanted, so the shared one takes +them as separate props rather than picking a winner and silently changing a +page. Same lesson as `isStandings`, found the same way: by going to add a fourth. + ## Charts Any chart added here must be read against the `dataviz` skill first. The diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index e12101c..cf6bdaf 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -23,9 +23,10 @@ use blackbeard_entities::{ MinerId, MinerSeriesPoint, NamedAccount, NetworkSummary, NodeActivity, NodeIndex, NodeInfo, NodeRow, PalletPot, PendingTransfer, RecentBlock, ReversibleState, RewardSummary, RoleSource, RuntimeConstant, RuntimeDetail, RuntimeField, RuntimePallet, RuntimeSignedExtension, - RuntimeStorage, RuntimeSummary, RuntimeVariant, StateEntry, TransferRoute, Window, - WormholeSummary, + RuntimeStorage, RuntimeSummary, RuntimeVariant, StateEntry, TransferRoute, VestingPoint, + VestingSchedule, VestingSummary, Window, WormholeSummary, }; +use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use tower_http::compression::CompressionLayer; use tower_http::cors::CorsLayer; @@ -58,6 +59,7 @@ pub fn router(state: AppState, allowed_origins: &[String]) -> Router { .route("/v1/chains/{chain}/blocks/{block}", get(block)) .route("/v1/chains/{chain}/miners/{miner}", get(miner)) .route("/v1/chains/{chain}/network", get(network)) + .route("/v1/chains/{chain}/vesting", get(vesting)) .route("/v1/chains/{chain}/distribution", get(distribution)) .route("/v1/chains/{chain}/wormhole", get(wormhole)) .route("/v1/chains/{chain}/nodes", get(nodes)) @@ -1640,6 +1642,273 @@ async fn roles( } /// The named accounts, recomputed only when stale. +/// How long a vesting reading is reused. +/// +/// The schedules themselves change only by a governance call. The *vested* +/// figures move continuously, but over a three-year window a minute is +/// invisible — and the release curve it draws does not move at all. +const VESTING_TTL: Duration = Duration::from_secs(300); + +/// Points on the release curve. +/// +/// Enough to show the shape of a three-year vest without asking the browser to +/// draw a mark per day. The curve is piecewise-linear by construction, so +/// sampling loses nothing a reader could see. +const VESTING_POINTS: usize = 72; + +/// `GET /v1/chains/{chain}/vesting` +/// +/// Every grant out of the vesting pot, and when it lands. See +/// [`blackbeard_entities::VestingSummary`] for why this is exact rather than a +/// forecast. +async fn vesting( + State(state): State, + Path(chain): Path, +) -> Result, Failure> { + let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?; + if let Some((computed, cached)) = runtime.vesting.read().await.as_ref() + && computed.elapsed() < VESTING_TTL + { + return Ok(Json(cached.clone())); + } + let fresh = compute_vesting(&runtime).await?; + *runtime.vesting.write().await = Some((std::time::Instant::now(), fresh.clone())); + Ok(Json(fresh)) +} + +/// Read `Vesting::Schedules`, decode each against its declared type, and work +/// out what has vested. +/// +/// Decoded through the metadata rather than by byte offset. The figures that +/// justified building this page at all were read positionally out of 89 bytes, +/// and they summed to the pot exactly — which is good evidence the layout was +/// guessed right and is still the shortcut this repository has a rule against. +/// A field reordered by a runtime upgrade would keep decoding, keep summing to +/// something, and be wrong about who gets paid what. +async fn compute_vesting( + runtime: &std::sync::Arc, +) -> Result { + let Some(parsed) = runtime.read().current_runtime() else { + return Err(Failure( + StatusCode::SERVICE_UNAVAILABLE, + ApiError::new( + "runtime_unknown", + "no runtime metadata cached for this chain yet", + ), + )); + }; + let Ok(map) = parsed.storage_map("Vesting", "Schedules") else { + return Err(Failure( + StatusCode::NOT_FOUND, + ApiError::new("no_vesting", "this runtime has no Vesting::Schedules"), + )); + }; + let prefix = format!("0x{}", hex::encode(&map.prefix)); + + let keys = runtime + .rpc + .storage_keys_paged(&prefix, blackbeard_data::rpc::MAX_KEYS_PER_PAGE, None, None) + .await + .map_err(|_| { + Failure( + StatusCode::BAD_GATEWAY, + ApiError::new("node_unavailable", "could not list the vesting schedules"), + ) + })?; + let values = runtime.rpc.storage_values(&keys).await.map_err(|_| { + Failure( + StatusCode::BAD_GATEWAY, + ApiError::new("node_unavailable", "could not read the vesting schedules"), + ) + })?; + + let now = Utc::now(); + let now_ms = now.timestamp_millis().max(0) as u64; + + let mut schedules = Vec::with_capacity(values.len()); + let mut raw: Vec = Vec::with_capacity(values.len()); + for (key, value) in &values { + let Some(bytes) = value else { continue }; + let Ok(decoded) = parsed.decode_map_value(&map, bytes) else { + continue; + }; + let Some(s) = read_schedule(&decoded) else { + continue; + }; + // The key's own bytes, not the hex string the node returned them as. + let id = hex::decode(key.trim_start_matches("0x")) + .ok() + .and_then(|raw| parsed.decode_map_key(&map, &raw)) + .and_then(|v| as_count(Some(v))) + .unwrap_or(0); + let beneficiary = decoded + .get("beneficiary") + .and_then(|b| b.as_str()) + .and_then(blackbeard_core::wormhole::ss58_of) + .unwrap_or_default(); + + schedules.push(VestingSchedule { + id, + beneficiary, + start: from_ms(s.start), + cliff: from_ms(s.cliff), + end: from_ms(s.end), + total: BigUintDec(s.total.to_string()), + claimed: BigUintDec(s.claimed.to_string()), + vested: BigUintDec(s.vested_at(now_ms).to_string()), + claimable: BigUintDec(s.claimable_at(now_ms).to_string()), + }); + raw.push(s); + } + + // Largest first: a reader wants the grants that move the supply, and 48 + // rows in map-key order is an arbitrary sequence. + schedules.sort_by(|a, b| { + b.total + .0 + .parse::() + .unwrap_or(0) + .cmp(&a.total.0.parse::().unwrap_or(0)) + }); + + let total_scheduled: u128 = raw.iter().map(|s| s.total).fold(0, u128::saturating_add); + let total_claimed: u128 = raw.iter().map(|s| s.claimed).fold(0, u128::saturating_add); + let total_vested: u128 = raw + .iter() + .map(|s| s.vested_at(now_ms)) + .fold(0, u128::saturating_add); + + // The pot, and whether the grants still account for it. Not asserted: this + // is the association the network page's wording rests on, and if a grant is + // ended or the pot topped up the site should say the two have parted + // company rather than keep describing one as the other. + let pot_account = parsed + .constant_bytes("Vesting", "PalletId") + .and_then(blackbeard_core::pallet::pallet_account); + let mut pot = None; + let mut pot_balance = 0u128; + if let Some(account) = pot_account { + let hex = hex::encode(account); + let (balance, _) = read_balance(runtime, &hex).await; + pot_balance = balance + .as_ref() + .and_then(|b| b.0.parse::().ok()) + .unwrap_or(0); + pot = blackbeard_core::wormhole::ss58_of(&hex).map(|address| PalletPot { + pallet: "Vesting".into(), + address, + balance, + share_bps: None, + }); + } + // Allowing for the existential deposit, which is what keeps the pot's + // account from being reaped and is therefore never part of any grant. + let ed = parsed + .constant_bytes("Balances", "ExistentialDeposit") + .and_then(|b| b.get(..16)) + .map(|b| u128::from_le_bytes(b.try_into().unwrap_or([0; 16]))) + .unwrap_or(0); + let shortfall = pot_balance.abs_diff(total_scheduled); + + Ok(VestingSummary { + chain: runtime.id(), + pot, + release: release_curve(&raw, now_ms), + schedules, + total_scheduled: BigUintDec(total_scheduled.to_string()), + total_claimed: BigUintDec(total_claimed.to_string()), + total_vested: BigUintDec(total_vested.to_string()), + matches_pot: pot_balance > 0 && shortfall <= ed, + pot_shortfall: BigUintDec(shortfall.to_string()), + computed_at: now, + }) +} + +/// Pull a schedule out of its decoded JSON, by field name. +fn read_schedule(v: &serde_json::Value) -> Option { + let moment = |name: &str| -> Option { + match v.get(name)? { + serde_json::Value::String(s) => s.parse().ok(), + serde_json::Value::Number(n) => n.as_u64(), + _ => None, + } + }; + let balance = |name: &str| -> Option { + match v.get(name)? { + serde_json::Value::String(s) => s.parse().ok(), + serde_json::Value::Number(n) => n.as_u128(), + _ => None, + } + }; + Some(blackbeard_core::vesting::Schedule { + start: moment("start")?, + cliff: moment("cliff")?, + end: moment("end")?, + total: balance("total")?, + claimed: balance("claimed")?, + }) +} + +/// Summed vested across every schedule, sampled across the whole window. +/// +/// Spans the earliest start to the latest end rather than starting at `now`, so +/// the shape of what has already happened is visible beside what has not — on a +/// chain where nothing has vested yet, a curve beginning today would be a +/// straight line from zero and would look like there was nothing to show. +fn release_curve( + schedules: &[blackbeard_core::vesting::Schedule], + now_ms: u64, +) -> Vec { + let first = schedules.iter().map(|s| s.start).min(); + let last = schedules.iter().map(|s| s.end).max(); + let (Some(first), Some(last)) = (first, last) else { + return Vec::new(); + }; + if last <= first { + return Vec::new(); + } + // A little before and after, so the flat approach and the flat tail are + // both visible rather than the curve starting mid-rise at the frame edge. + let span = last - first; + let from = first.saturating_sub(span / 12); + let to = last.saturating_add(span / 12); + let step = (to - from) / VESTING_POINTS as u64; + if step == 0 { + return Vec::new(); + } + + let mut out = Vec::with_capacity(VESTING_POINTS + 2); + let mut at = from; + while at <= to { + let vested: u128 = schedules + .iter() + .map(|s| s.vested_at(at)) + .fold(0, u128::saturating_add); + out.push(VestingPoint { + at: from_ms(at), + vested: BigUintDec(vested.to_string()), + }); + at = at.saturating_add(step); + } + // `now` explicitly, so the marker the UI draws sits on the line rather than + // between two samples of it. + let vested_now: u128 = schedules + .iter() + .map(|s| s.vested_at(now_ms)) + .fold(0, u128::saturating_add); + out.push(VestingPoint { + at: from_ms(now_ms), + vested: BigUintDec(vested_now.to_string()), + }); + out.sort_by_key(|p| p.at); + out +} + +/// Milliseconds since the epoch as a timestamp, clamped rather than panicking. +fn from_ms(ms: u64) -> DateTime { + DateTime::from_timestamp_millis(ms.min(i64::MAX as u64) as i64).unwrap_or_default() +} + /// How long a concentration reading is reused. /// /// Longer than the roles TTL: this walks every account on the chain, and the @@ -1800,7 +2069,7 @@ async fn compute_concentration( top10_bps: share(10), top100_bps: share(100), gini: blackbeard_core::concentration::gini(&balances), - computed_at: chrono::Utc::now(), + computed_at: Utc::now(), }) } diff --git a/crates/blackbeard-api/src/state.rs b/crates/blackbeard-api/src/state.rs index d49ef80..fae3f92 100644 --- a/crates/blackbeard-api/src/state.rs +++ b/crates/blackbeard-api/src/state.rs @@ -175,6 +175,9 @@ pub struct ChainRuntime { /// one and has no business happening on a page load. pub concentration: tokio::sync::RwLock>, + /// The vesting schedules and what they have released, cached the same way. + pub vesting: + tokio::sync::RwLock>, /// Blocks awaiting their telemetry attribution. pub pending_attributions: std::sync::Mutex>, /// Live subscriber count per window, so the recompute timer can skip a @@ -219,6 +222,7 @@ impl ChainRuntime { events, roles: tokio::sync::RwLock::new(None), concentration: tokio::sync::RwLock::new(None), + vesting: tokio::sync::RwLock::new(None), pending_attributions: std::sync::Mutex::new(std::collections::VecDeque::new()), subscribers: Default::default(), } diff --git a/crates/blackbeard-core/src/lib.rs b/crates/blackbeard-core/src/lib.rs index 20e1187..df64480 100644 --- a/crates/blackbeard-core/src/lib.rs +++ b/crates/blackbeard-core/src/lib.rs @@ -22,6 +22,7 @@ pub mod pallet; pub mod runtime; pub mod scale; pub mod series; +pub mod vesting; pub mod window; pub mod wormhole; diff --git a/crates/blackbeard-core/src/runtime.rs b/crates/blackbeard-core/src/runtime.rs index 0cae671..1d7917d 100644 --- a/crates/blackbeard-core/src/runtime.rs +++ b/crates/blackbeard-core/src/runtime.rs @@ -1543,6 +1543,77 @@ mod tests { assert_eq!(rt.constant_bytes("NoSuchPallet", "PalletId"), None); } + /// A real `Vesting::Schedules` entry, decoded against the real metadata. + /// + /// The figures that justified building the vesting route were read out of + /// these 89 bytes **positionally**, and they summed to the pot exactly — + /// which is good evidence the layout was guessed right and is exactly the + /// shortcut this repository has a rule against. A runtime that reordered + /// the struct would keep decoding by offset, keep summing to something, and + /// be wrong about who gets paid what. This pins the field *names* the route + /// reads and the values behind them. + #[test] + fn a_vesting_schedule_decodes_by_name_against_real_metadata() { + let rt = Runtime::from_metadata(&metadata()).expect("parses"); + let map = rt + .storage_map("Vesting", "Schedules") + .expect("this runtime has Vesting::Schedules"); + let raw = hex::decode( + include_str!("../tests/mainnet-vesting-schedule.hex") + .trim() + .trim_start_matches("0x"), + ) + .expect("fixture is hex"); + + let decoded = rt.decode_map_value(&map, &raw).expect("decodes"); + let field = |n: &str| decoded.get(n).cloned().unwrap_or_default(); + + assert_eq!( + field("beneficiary").as_str(), + Some("0xa54f8eb77d7b3e5ab13c1bf5a93310b3d22e8ed0c1a6dde87e0eac3f5d97a77a"), + ); + // Numbers may arrive as JSON numbers or as strings depending on width; + // the route accepts both, so the test asserts on the rendered form. + let num = |n: &str| -> u128 { + match field(n) { + serde_json::Value::String(s) => s.parse().expect("numeric string"), + serde_json::Value::Number(x) => x.as_u128().expect("fits"), + other => panic!("{n} was {other:?}"), + } + }; + assert_eq!(num("start"), 1_820_479_917_807); + assert_eq!(num("cliff"), 1_820_479_917_807); + assert_eq!(num("end"), 1_915_087_917_807); + assert_eq!(num("total"), 52_500_000_000_000_000); + assert_eq!(num("claimed"), 0); + + // start == cliff on this schedule, so it vests linearly with no step. + // If a future runtime separates them the vesting maths has a branch for + // it, and this assertion is the thing that notices. + assert_eq!(num("start"), num("cliff")); + } + + /// The map key is a schedule id, and the route renders it. Decoded from the + /// key's own trailing bytes rather than by counting hashers by hand. + #[test] + fn a_vesting_schedule_key_yields_its_id() { + let rt = Runtime::from_metadata(&metadata()).expect("parses"); + let map = rt.storage_map("Vesting", "Schedules").expect("present"); + let key = hex::decode( + include_str!("../tests/mainnet-vesting-key.hex") + .trim() + .trim_start_matches("0x"), + ) + .expect("fixture is hex"); + let id = rt.decode_map_key(&map, &key).expect("decodes"); + let n = match id { + serde_json::Value::String(s) => s.parse::().expect("numeric"), + serde_json::Value::Number(x) => x.as_u64().expect("fits"), + other => panic!("id was {other:?}"), + }; + assert_eq!(n, 6, "the id is the map key's own suffix"); + } + #[test] fn a_runtime_describes_its_own_pallets() { let rt = Runtime::from_metadata(&metadata()).expect("parses"); diff --git a/crates/blackbeard-core/src/vesting.rs b/crates/blackbeard-core/src/vesting.rs new file mode 100644 index 0000000..8b7d74e --- /dev/null +++ b/crates/blackbeard-core/src/vesting.rs @@ -0,0 +1,191 @@ +//! How much of a grant has vested at a moment. +//! +//! A restatement of `pallet_vesting::vested_amount`, here rather than beside +//! its caller because it is arithmetic with no I/O in it — and because a +//! release schedule is a claim about the future, which is the one kind of +//! number on this site that cannot be checked against the chain until the day +//! it comes true. Until 2027 the only thing standing behind it is this +//! function agreeing with the runtime's. + +/// Milliseconds since the unix epoch, as the pallet counts them. +pub type Moment = u64; + +/// A grant, as `Vesting::Schedules` holds it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Schedule { + /// When linear accrual begins. + pub start: Moment, + /// Before this, nothing is claimable. At it, everything accrued since + /// `start` unlocks at once — so a schedule with `start < cliff` has a step + /// in it, and one with `start == cliff` does not. + pub cliff: Moment, + /// When the whole grant has vested. + pub end: Moment, + /// The grant. + pub total: u128, + /// What the beneficiary has already taken. + pub claimed: u128, +} + +impl Schedule { + /// How much has vested by `now`. + /// + /// Zero before the cliff, `total` from `end`, linear between — the + /// runtime's own three branches, in its own order. The `end` branch is not + /// an optimisation: it is what guarantees the final figure is exactly + /// `total` rather than `total` less a rounding crumb. + pub fn vested_at(&self, now: Moment) -> u128 { + if now < self.cliff { + return 0; + } + if now >= self.end { + return self.total; + } + // `cliff <= now < end` and `start <= cliff`, so both differences are in + // range and the duration is non-zero. + let elapsed = u128::from(now.saturating_sub(self.start)); + let duration = u128::from(self.end.saturating_sub(self.start)); + if duration == 0 { + return self.total; + } + mul_div_floor(self.total, elapsed, duration) + } + + /// What the beneficiary could take right now. + /// + /// Saturating rather than wrapping: `claimed` exceeding `vested` should be + /// impossible, and if the chain ever says otherwise the honest answer is + /// "nothing available", not a number near `u128::MAX`. + pub fn claimable_at(&self, now: Moment) -> u128 { + self.vested_at(now).saturating_sub(self.claimed) + } +} + +/// `floor(a · b / m)`, without overflowing on the way. +/// +/// The runtime does this in 256 bits. Here the product is split instead: +/// `a = q·m + r`, so `a·b/m = q·b + r·b/m`, and each term stays far inside a +/// `u128` for any input this chain can produce. It matters — a naive +/// `total * elapsed` for one of mainnet's 840,000 QTC grants over a three-year +/// window is 8×10²⁸, which fits, but the same expression on a grant a hundred +/// times larger would not, and would wrap into a small plausible number rather +/// than failing. +fn mul_div_floor(a: u128, b: u128, m: u128) -> u128 { + if m == 0 { + return 0; + } + let (q, r) = (a / m, a % m); + q.saturating_mul(b).saturating_add(r.saturating_mul(b) / m) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A grant shaped like mainnet's: 840,000 QTC over three years, no step. + fn grant() -> Schedule { + Schedule { + start: 1_788_943_917_807, + cliff: 1_788_943_917_807, + end: 1_788_943_917_807 + THREE_YEARS, + total: 840_000 * UNIT, + claimed: 0, + } + } + const UNIT: u128 = 1_000_000_000_000; + const THREE_YEARS: u64 = 3 * 365 * 24 * 60 * 60 * 1_000; + + #[test] + fn nothing_vests_before_the_cliff() { + let s = grant(); + assert_eq!(s.vested_at(0), 0); + assert_eq!(s.vested_at(s.cliff - 1), 0); + } + + #[test] + fn everything_has_vested_at_the_end_exactly() { + let s = grant(); + // Exactly `total`, not `total` less a rounding crumb: this is the whole + // reason the runtime special-cases `end` rather than letting the linear + // branch run to completion. + assert_eq!(s.vested_at(s.end), s.total); + assert_eq!(s.vested_at(s.end + THREE_YEARS), s.total); + } + + #[test] + fn half_way_through_is_half_the_grant() { + let s = grant(); + let half = s.vested_at(s.start + THREE_YEARS / 2); + assert_eq!(half, s.total / 2); + } + + /// `start < cliff` means a chunk unlocks at once when the cliff lands — + /// the case a linear-only implementation gets wrong, and the case the + /// mainnet schedules happen not to use. + #[test] + fn a_cliff_after_the_start_unlocks_its_accrual_in_one_step() { + let s = Schedule { + start: 0, + cliff: THREE_YEARS / 3, + end: THREE_YEARS, + total: 900 * UNIT, + claimed: 0, + }; + assert_eq!(s.vested_at(s.cliff - 1), 0); + // A third of the way in, a third of the grant — arriving all at once. + assert_eq!(s.vested_at(s.cliff), 300 * UNIT); + } + + #[test] + fn claimable_is_what_is_vested_less_what_was_taken() { + let mut s = grant(); + s.claimed = 100_000 * UNIT; + let at = s.start + THREE_YEARS / 2; + assert_eq!(s.claimable_at(at), 420_000 * UNIT - 100_000 * UNIT); + // Before the cliff nothing is claimable even though something was taken. + assert_eq!(s.claimable_at(0), 0); + } + + /// An over-claim cannot become an enormous positive number. + #[test] + fn claiming_more_than_vested_reads_as_nothing_available() { + let s = Schedule { + claimed: 900 * UNIT, + ..grant() + }; + assert_eq!(s.claimable_at(s.start + 1), 0); + } + + /// The reason the product is split. A grant large enough that + /// `total * elapsed` would overflow a `u128` still vests linearly rather + /// than wrapping into something small and believable. + #[test] + fn an_enormous_grant_does_not_wrap() { + let s = Schedule { + start: 0, + cliff: 0, + end: THREE_YEARS, + total: u128::MAX / 2, + claimed: 0, + }; + let half = s.vested_at(THREE_YEARS / 2); + let expected = u128::MAX / 4; + // Within a rounding crumb of half, and nowhere near zero or the moon. + assert!(half.abs_diff(expected) < expected / 1_000_000, "{half}"); + } + + /// Monotonic: a schedule can never vest less as time passes. Cheap to + /// check across the whole window and the property a reader assumes. + #[test] + fn vesting_never_goes_backwards() { + let s = grant(); + let mut previous = 0; + for step in 0..=100u64 { + let now = s.start + (THREE_YEARS / 100) * step; + let vested = s.vested_at(now); + assert!(vested >= previous, "went backwards at step {step}"); + previous = vested; + } + assert_eq!(previous, s.total); + } +} diff --git a/crates/blackbeard-core/tests/mainnet-vesting-key.hex b/crates/blackbeard-core/tests/mainnet-vesting-key.hex new file mode 100644 index 0000000..094962c --- /dev/null +++ b/crates/blackbeard-core/tests/mainnet-vesting-key.hex @@ -0,0 +1 @@ +0x5f27b51b5ec208ee9cb25b55d8728243347d9208c3a12dab846866426727fa0b0349a011341eed9a0600000000000000 diff --git a/crates/blackbeard-core/tests/mainnet-vesting-schedule.hex b/crates/blackbeard-core/tests/mainnet-vesting-schedule.hex new file mode 100644 index 0000000..1b1ba95 --- /dev/null +++ b/crates/blackbeard-core/tests/mainnet-vesting-schedule.hex @@ -0,0 +1 @@ +0xa54f8eb77d7b3e5ab13c1bf5a93310b3d22e8ed0c1a6dde87e0eac3f5d97a77aef0e0fdda7010000ef0e0fdda7010000ef9222e4bd0100000040b5ca7884ba0000000000000000000000000000000000000000000000000000 diff --git a/crates/blackbeard-entities/src/chain.rs b/crates/blackbeard-entities/src/chain.rs index c148ccd..dab3c69 100644 --- a/crates/blackbeard-entities/src/chain.rs +++ b/crates/blackbeard-entities/src/chain.rs @@ -303,6 +303,87 @@ pub struct NetworkSummary { pub days: Vec, } +/// One grant out of the vesting pot. +/// +/// **A snapshot, never a promise.** `Vesting::retarget_schedule` can change a +/// beneficiary and `end_schedule` can end a grant, so every field here +/// describes what the chain says today rather than what anybody is owed. +/// +/// The beneficiary is an address and nothing more. Nothing on chain calls +/// anyone a founder, a team or an investor, so nothing here does either. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "VestingSchedule.ts")] +pub struct VestingSchedule { + /// Its id in the map. + #[ts(type = "number")] + pub id: u64, + /// Who it pays, SS58. + pub beneficiary: String, + /// When linear accrual begins. + pub start: DateTime, + /// Before this nothing is claimable; at it, everything accrued since + /// `start` unlocks at once. Equal to `start` on every mainnet schedule, + /// which is why none of them has a step. + pub cliff: DateTime, + /// When the whole grant has vested. + pub end: DateTime, + /// The grant. + pub total: BigUintDec, + /// Taken so far. + pub claimed: BigUintDec, + /// Vested as of `computed_at` on the summary. + pub vested: BigUintDec, + /// Vested less claimed — what could be taken right now. + pub claimable: BigUintDec, +} + +/// One instant on the release curve. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "VestingPoint.ts")] +pub struct VestingPoint { + /// When. + pub at: DateTime, + /// Summed vested across every schedule at that moment, smallest unit. + pub vested: BigUintDec, +} + +/// Every grant out of the vesting pot, and when it lands. +/// +/// The only forward-looking page on this site, and exact rather than projected: +/// the runtime vests zero before the cliff, the whole grant from `end`, and +/// linearly between, so summing that across the schedules is arithmetic on +/// chain state rather than a forecast. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "VestingSummary.ts")] +pub struct VestingSummary { + /// Which chain. + pub chain: ChainId, + /// The pot the grants are paid from, and what it holds. + pub pot: Option, + /// Every schedule, largest first. + pub schedules: Vec, + /// Summed grants. + pub total_scheduled: BigUintDec, + /// Summed claims. + pub total_claimed: BigUintDec, + /// Summed vested as of now. + pub total_vested: BigUintDec, + /// Whether the schedules still account for the pot's balance, allowing for + /// the existential deposit that keeps the account alive. + /// + /// This is the association the network page's wording rests on. Reported + /// rather than assumed: if a grant is ever ended or the pot is topped up, + /// the page should say the two have parted company rather than keep + /// describing one as the other. + pub matches_pot: bool, + /// The difference, smallest unit, whichever way it falls. + pub pot_shortfall: BigUintDec, + /// The release curve, oldest first. + pub release: Vec, + /// When the vested figures were computed. + pub computed_at: DateTime, +} + /// How concentrated the balance outside the vesting pot is. /// /// Three things make the obvious version of this figure meaningless on this diff --git a/crates/blackbeard-entities/src/lib.rs b/crates/blackbeard-entities/src/lib.rs index 34694a3..0be8eef 100644 --- a/crates/blackbeard-entities/src/lib.rs +++ b/crates/blackbeard-entities/src/lib.rs @@ -34,7 +34,8 @@ pub use call::{CallIndex, CallSummary, EventSummary}; pub use chain::{ AccountFlows, ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, Concentration, DailyActivity, DailyWormhole, Distribution, ExitCohort, LockCounts, MinerFlow, NetworkSummary, - PalletPot, Tracking, TransferRoute, WormholeSummary, + PalletPot, Tracking, TransferRoute, VestingPoint, VestingSchedule, VestingSummary, + WormholeSummary, }; pub use error::{ApiError, EntityError}; pub use miner::{ diff --git a/web/src/App.tsx b/web/src/App.tsx index 1452463..85c54d2 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -33,6 +33,7 @@ import { SectionNav } from './components/SectionNav' import { ThemeToggle } from './components/ThemeToggle' import { StateIndex } from './components/StateIndex' import { StatBar } from './components/StatBar' +import { VestingPanel } from './components/VestingPanel' import { WormholeStage } from './components/WormholeStage' import { measuredSpan, seconds, windowSpan } from './lib/format' import { href, isStandings, parse, WINDOWS } from './lib/routes' @@ -284,6 +285,13 @@ export default function App() { )} {route.index === 'node' && chain && } + {route.index === 'vesting' && chain && ( + + )} {route.index === 'wormhole' && chain && ( , +/** + * Summed grants. + */ +total_scheduled: BigUintDec, +/** + * Summed claims. + */ +total_claimed: BigUintDec, +/** + * Summed vested as of now. + */ +total_vested: BigUintDec, +/** + * Whether the schedules still account for the pot's balance, allowing for + * the existential deposit that keeps the account alive. + * + * This is the association the network page's wording rests on. Reported + * rather than assumed: if a grant is ever ended or the pot is topped up, + * the page should say the two have parted company rather than keep + * describing one as the other. + */ +matches_pot: boolean, +/** + * The difference, smallest unit, whichever way it falls. + */ +pot_shortfall: BigUintDec, +/** + * The release curve, oldest first. + */ +release: Array, +/** + * When the vested figures were computed. + */ +computed_at: string, }; diff --git a/web/src/api/rest.ts b/web/src/api/rest.ts index 706507f..1b22457 100644 --- a/web/src/api/rest.ts +++ b/web/src/api/rest.ts @@ -22,6 +22,7 @@ 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 { VestingSummary } from './generated/VestingSummary' import type { WormholeSummary } from './generated/WormholeSummary' import type { NodeIndex } from './generated/NodeIndex' import type { NodeInfo } from './generated/NodeInfo' @@ -272,3 +273,8 @@ export async function fetchDistribution( export async function fetchWormhole(chain: string, signal?: AbortSignal): Promise { return get(`/chains/${chain}/wormhole`, signal) } + +/** Every grant out of the vesting pot, and the curve they release on. */ +export async function fetchVesting(chain: string, signal?: AbortSignal): Promise { + return get(`/chains/${chain}/vesting`, signal) +} diff --git a/web/src/components/Field.tsx b/web/src/components/Field.tsx new file mode 100644 index 0000000..5885468 --- /dev/null +++ b/web/src/components/Field.tsx @@ -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 ( +
+
{label}
+
+ {value} +
+ {note &&
{note}
} +
+ ) +} diff --git a/web/src/components/NetworkPanel.tsx b/web/src/components/NetworkPanel.tsx index b6faa3b..3858bf0 100644 --- a/web/src/components/NetworkPanel.tsx +++ b/web/src/components/NetworkPanel.tsx @@ -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 ( -
-
{label}
-
{value}
- {note &&
{note}
} -
- ) -} +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". */ diff --git a/web/src/components/NodePanel.tsx b/web/src/components/NodePanel.tsx index 22d255e..2cbe6cc 100644 --- a/web/src/components/NodePanel.tsx +++ b/web/src/components/NodePanel.tsx @@ -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 ( -
-
{label}
-
- {value} -
-
- ) -} - export function NodePanel({ node, confidence, @@ -108,8 +90,10 @@ export function NodePanel({ {node.version && } {node.implementation && } {os && } - {country && } - {node.cpu && } + {country && ( + + )} + {node.cpu && } {node.core_count !== null && } {node.memory !== null && } {node.linux_distro && } @@ -121,7 +105,7 @@ export function NodePanel({ )} diff --git a/web/src/components/VestingPanel.tsx b/web/src/components/VestingPanel.tsx new file mode 100644 index 0000000..8bbc064 --- /dev/null +++ b/web/src/components/VestingPanel.tsx @@ -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, number] { + const ref = useRef(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 ( +
+ + {yTicks.map((t) => ( + + + + {t.v === 0 ? '0' : `${Math.round(t.v / 1000)}k`} + + + ))} + {xTicks.map((t, i) => ( + + {month(new Date(t.t).toISOString())} + + ))} + + + + + {/* 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 && ( + <> + + + now + + + )} + +
+ ) +} + +export function VestingPanel({ + chain, + decimals, + symbol, +}: { + chain: string + decimals: number + symbol: string +}) { + const [data, setData] = useState(null) + const [error, setError] = useState(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

{error}

+ if (!data) return

Reading the schedules…

+ if (data.schedules.length === 0) { + return

This chain has no vesting schedules.

+ } + + 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 ( + <> +
+
+

Vesting

+ read from chain state +
+
+ + + + + + +
+

+ {data.matches_pot ? ( + <> + The grants account for the pot exactly. They sum to{' '} + {amount(data.total_scheduled)} against the{' '} + {data.pot?.balance ? amount(data.pot.balance) : 'balance'} held at{' '} + {data.pot ? {shortAddress(data.pot.address)} : '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{' '} + PalletId that happen to spell qvesting. + + ) : ( + <> + The grants no longer account for the pot. 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. + + )} +

+
+ +
+
+

When it arrives

+ cumulative, in {symbol} +
+ +

+ 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. +

+
+ +
+
+

Every grant

+ largest first · amounts in {symbol} +
+
+ + + + + + + + + + + + + + {data.schedules.map((s) => ( + + + + + + + + + ))} + +
Vesting grants, largest first
+ Beneficiary + GrantVestedClaimed + Unlocks + + Fully vested +
+ + {shortAddress(s.beneficiary)} + + {tokens(s.total, decimals, 0)}{tokens(s.vested, decimals, 0)}{tokens(s.claimed, decimals, 0)}{day(s.cliff)}{day(s.end)}
+
+

+ A snapshot, never a promise. Vesting::retarget_schedule can + change who a grant pays and end_schedule can end one, so every row describes + what the chain says today rather than what anybody is owed. +

+

+ 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. +

+
+ + ) +} diff --git a/web/src/components/WormholePanel.tsx b/web/src/components/WormholePanel.tsx index a810af9..269b042 100644 --- a/web/src/components/WormholePanel.tsx +++ b/web/src/components/WormholePanel.tsx @@ -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 ( -
-
{label}
-
{value}
- {note &&
{note}
} -
- ) -} +import { Field } from './Field' /** The route's own name, since block initialisation has no extrinsic. */ function routeName(produced_by: string): string { diff --git a/web/src/lib/routes.ts b/web/src/lib/routes.ts index 1eaf25a..8192a9a 100644 --- a/web/src/lib/routes.ts +++ b/web/src/lib/routes.ts @@ -116,6 +116,7 @@ export type SectionName = | 'node' | 'network' | 'wormhole' + | 'vesting' /** * The sections, in the order the nav shows them. @@ -143,6 +144,9 @@ export const SECTIONS: { id: SectionName; label: string }[] = [ // is this economy doing — and because the two are read together: the // distribution table's blindness is explained here. { id: 'wormhole', label: 'Wormhole' }, + // Last, and the only section that is about the future: every other page here + // reports what the chain has already done. + { id: 'vesting', label: 'Vesting' }, ] function section(name: string): SectionName | null {