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 {