diff --git a/CLAUDE.md b/CLAUDE.md index 2397cdc..d07e436 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,6 +11,25 @@ Conventions: `~/git/architecture` — `generic.md` for the workspace shape, `deployment-gitea-actions.md` for the deploy, `port-allocations.md` for the port, `reverse-proxies.md` and `external-tls.md`/`internal-tls.md` for the vhosts. +## Work is filed before it is done + +Most fixes and features start as a Gitea issue on +`quantus/blackbeard.observer`, and the commit that implements one closes it: + +``` +Closes #12 +``` + +The point is an auditable history — why a thing was built, what was believed at +the time, and what turned out to be wrong on the way. That last part is the +valuable half, so **when an investigation contradicts the issue, comment on the +issue rather than quietly building the right thing**. Issue #1 asserted that +`execute_at` came from `PendingTransfers`; it does not, and the correction is a +comment there, dated, rather than a surprise in a diff. + +An issue is not a ceremony for a typo. It is for anything where somebody later +would reasonably ask "why is it like this". + ## The things that cost an afternoon **substrate-telemetry sends its JSON in *binary* WebSocket frames, not text.** A diff --git a/crates/blackbeard-api/src/routes.rs b/crates/blackbeard-api/src/routes.rs index 365d4e8..14e8ea8 100644 --- a/crates/blackbeard-api/src/routes.rs +++ b/crates/blackbeard-api/src/routes.rs @@ -18,9 +18,9 @@ use axum::{Json, Router}; use blackbeard_entities::{ AccountDetail, AccountEvent, AccountRow, ActivitySource, ApiError, BigUintDec, BlockDetail, CallIndex, CallSummary, ChainInfo, ChainSeries, ChainState, ChainSummary, EventSummary, - LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint, RecentBlock, RewardSummary, - RuntimeConstant, RuntimeDetail, RuntimeField, RuntimePallet, RuntimeSignedExtension, - RuntimeStorage, RuntimeSummary, RuntimeVariant, StateEntry, Window, + LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint, PendingTransfer, RecentBlock, + ReversibleState, RewardSummary, RuntimeConstant, RuntimeDetail, RuntimeField, RuntimePallet, + RuntimeSignedExtension, RuntimeStorage, RuntimeSummary, RuntimeVariant, StateEntry, Window, }; use serde::{Deserialize, Serialize}; use tower_http::compression::CompressionLayer; @@ -62,6 +62,7 @@ pub fn router(state: AppState, allowed_origins: &[String]) -> Router { get(event_feed), ) .route("/v1/chains/{chain}/state", get(chain_state)) + .route("/v1/chains/{chain}/reversible", get(reversible)) .route("/v1/chains/{chain}/runtimes", get(runtimes)) .route("/v1/chains/{chain}/runtimes/{spec}", get(runtime)) .route("/v1/ws", get(crate::ws::handler)) @@ -737,6 +738,177 @@ async fn accounts( )) } +/// How many pending transfers one request will enumerate. +/// +/// `MaxPendingPerAccount` is 16, so this is a hundred accounts' worth. A cap +/// exists because enumeration is one RPC round trip per entry and an unbounded +/// map would be an unbounded page. +const PENDING_LIMIT: u32 = 1_600; + +/// Money in flight, and still cancellable. +/// +/// A join of both halves of the observer, each authoritative about a different +/// thing. `PendingTransfers` in **state** says what is still pending — a +/// cancelled or executed transfer is simply gone from the map, and that absence +/// is more trustworthy than replaying every event since genesis. The **event +/// index** supplies `execute_at`, because `TransactionScheduled` carries it and +/// the stored struct does not. +async fn reversible( + State(state): State, + Path(chain): Path, +) -> Result, Failure> { + let runtime = state.chain(&chain).ok_or_else(|| unknown_chain(&chain))?; + let id = runtime.id(); + let (parsed, height) = { + let inner = runtime.read(); + (inner.current_runtime(), inner.height) + }; + let Some(parsed) = parsed else { + return Err(Failure( + StatusCode::SERVICE_UNAVAILABLE, + ApiError::new( + "runtime_unknown", + "no runtime metadata cached for this chain yet", + ), + )); + }; + + let summary = runtime.summary(); + let described = parsed.describe(); + // The configured delay, read from the runtime rather than assumed: it is + // what makes a countdown of "6,912 blocks" mean anything. + let default_delay_blocks = described + .pallets + .iter() + .find(|p| p.name == "ReversibleTransfers") + .and_then(|p| p.constants.iter().find(|c| c.name == "DefaultDelay")) + .and_then(|c| c.value.as_ref()) + .and_then(|v| v.get("BlockNumber").and_then(serde_json::Value::as_u64)); + + let map = parsed + .storage_map("ReversibleTransfers", "PendingTransfers") + .map_err(|e| { + // A runtime without the pallet is not an error the caller caused. + Failure( + StatusCode::NOT_FOUND, + ApiError::new("no_reversible_transfers", e.to_string()), + ) + })?; + + // When each one is due. Fetched before the state walk so a slow enumeration + // does not leave the join half-done. + let scheduled = state + .store + .events_of_kind( + &id, + "ReversibleTransfers", + "TransactionScheduled", + None, + 1_000, + ) + .await + .map_err(database_unavailable)?; + let due: std::collections::HashMap = scheduled + .iter() + .filter_map(|e| Some((e.fields.get("tx_id")?.as_str()?.to_owned(), e))) + .collect(); + + let prefix = format!("0x{}", hex::encode(&map.prefix)); + let keys = runtime + .rpc + .storage_keys_paged(&prefix, PENDING_LIMIT, None) + .await + .unwrap_or_default(); + + let mut pending = Vec::new(); + for key in keys { + let Ok(raw_key) = hex::decode(key.trim_start_matches("0x")) else { + continue; + }; + let Ok(Some(raw)) = runtime.rpc.storage(&key, None).await else { + continue; + }; + let Ok(entry) = parsed.decode_map_value(&map, &raw) else { + continue; + }; + let tx_id = parsed + .decode_map_key(&map, &raw_key) + .and_then(|v| v.as_str().map(str::to_owned)) + .unwrap_or_else(|| key.clone()); + + // The three accounts and the amount, by the names the pallet gives + // them. The whole entry rides alongside, so this convenience is + // checkable and a field added later is still visible. + let account = |name: &str| entry.get(name).and_then(|v| v.as_str()).map(str::to_owned); + let (from, to, guardian) = (account("from"), account("to"), account("guardian")); + + let event = due.get(&tx_id); + let execute_at = event.and_then(|e| execute_at_block(&e.fields)); + + pending.push(PendingTransfer { + from_address: from.as_deref().and_then(blackbeard_core::wormhole::ss58_of), + to_address: to.as_deref().and_then(blackbeard_core::wormhole::ss58_of), + guardian_address: guardian + .as_deref() + .and_then(blackbeard_core::wormhole::ss58_of), + from, + to, + guardian, + amount: entry.get("amount").and_then(|v| match v { + serde_json::Value::String(s) => Some(BigUintDec(s.clone())), + serde_json::Value::Number(n) => Some(BigUintDec(n.to_string())), + _ => None, + }), + // Signed: the scheduler can run behind, and an overdue transfer is + // a real state worth showing rather than clamping to zero. + blocks_remaining: match (execute_at, height) { + (Some(at), Some(now)) => Some(at as i64 - now as i64), + _ => None, + }, + execute_at, + scheduled_at_height: event.map(|e| e.height), + scheduled_at: event.and_then(|e| e.at), + entry, + tx_id, + }); + } + // Soonest first, and the ones with no known deadline last: an unknown + // countdown sorted among the known ones would imply an ordering it has not + // earned. + pending.sort_by_key(|p| { + ( + p.blocks_remaining.is_none(), + p.blocks_remaining.unwrap_or(i64::MAX), + ) + }); + + Ok(Json(ReversibleState { + chain: id, + height, + block_seconds: summary + .block_interval_seconds + .or(Some(summary.target_block_time_seconds)), + default_delay_blocks, + pending, + })) +} + +/// The block a `DispatchTime` names, when it names one. +/// +/// `execute_at` is `DispatchTime` — an enum, so it is a +/// block on one arm and a timestamp on another, and assuming the block arm +/// would silently read a millisecond timestamp as a height. +fn execute_at_block(fields: &serde_json::Value) -> Option { + let at = fields.get("execute_at")?; + // `normalise` renders a single-field variant as `{"At": value}`. + for arm in ["At", "Block", "BlockNumber"] { + if let Some(v) = at.get(arm) { + return v.as_u64().or_else(|| v.as_str()?.parse().ok()); + } + } + at.as_u64() +} + /// Every storage entry readable without a key, decoded. /// /// The half of a chain that history cannot reach. This chain's treasury address diff --git a/crates/blackbeard-core/src/runtime.rs b/crates/blackbeard-core/src/runtime.rs index 0399079..f059a96 100644 --- a/crates/blackbeard-core/src/runtime.rs +++ b/crates/blackbeard-core/src/runtime.rs @@ -244,6 +244,36 @@ pub struct StorageTarget { pub default: Option>, } +/// A storage map: where its entries live and what they decode as. +#[derive(Debug, Clone, PartialEq)] +pub struct StorageMap { + /// `twox128(pallet prefix) ++ twox128(item)`. Every key in the map starts + /// with this, and it is what enumeration asks for. + pub prefix: Vec, + /// The registry type a key decodes as. + pub key_ty: u32, + /// The registry type a value decodes as. + pub value_ty: u32, + /// Bytes of hash sitting before the key inside each storage key, when the + /// hasher kept the key at all. `None` when it did not, or when the map + /// takes more than one key. + pub key_offset: Option, +} + +/// How many bytes of hash a hasher puts before the key it keeps. +/// +/// `None` for the hashers that discard the key — a map using one can be +/// enumerated and counted but its entries cannot be named. +fn hash_prefix_len(hasher: &frame_metadata::v14::StorageHasher) -> Option { + use frame_metadata::v14::StorageHasher as H; + match hasher { + H::Blake2_128Concat => Some(16), + H::Twox64Concat => Some(8), + H::Identity => Some(0), + H::Blake2_128 | H::Blake2_256 | H::Twox128 | H::Twox256 => None, + } +} + /// `twox128`, as Substrate builds storage prefixes: two little-endian xxhash64 /// digests, seeded 0 and 1, concatenated. fn twox_128(input: &[u8]) -> [u8; 16] { @@ -699,6 +729,103 @@ impl Runtime { }) } + /// A storage map's prefix, and what its keys and values decode as. + /// + /// The prefix alone is what `state_getKeysPaged` enumerates on, which is + /// the only way to ask "what is in this map" from outside the runtime — + /// there is no list, only keys derived from the things in it. + /// + /// Whether the key is *recoverable* from a storage key depends on the + /// hasher. The `Concat` variants keep the key after its hash, so it can be + /// read back; the plain ones do not, and such a map can be enumerated but + /// not attributed. `key_offset` says which, and is `None` for the second + /// case rather than a guess. + pub fn storage_map(&self, pallet: &str, item: &str) -> Result { + let pallet_meta = self + .metadata + .pallets + .iter() + .find(|p| p.name == pallet) + .ok_or_else(|| RuntimeError::NoStorageEntry(format!("no pallet {pallet}")))?; + let storage = pallet_meta + .storage + .as_ref() + .ok_or_else(|| RuntimeError::NoStorageEntry(format!("{pallet} declares no storage")))?; + let entry = storage + .entries + .iter() + .find(|e| e.name == item) + .ok_or_else(|| RuntimeError::NoStorageEntry(format!("no {pallet}::{item}")))?; + + let frame_metadata::v14::StorageEntryType::Map { + hashers, + key: key_ty, + value, + } = &entry.ty + else { + return Err(RuntimeError::NoStorageEntry(format!( + "{pallet}::{item} is a plain entry, not a map" + ))); + }; + + let mut prefix = Vec::with_capacity(32); + prefix.extend_from_slice(&twox_128(storage.prefix.as_bytes())); + prefix.extend_from_slice(&twox_128(item.as_bytes())); + + // Only a single-key map yields a recoverable key here. A double map's + // storage key interleaves two hashes and two keys, and picking that + // apart needs the key type's own encoded length rather than an offset. + let key_offset = match (hashers.len(), hashers.first()) { + (1, Some(h)) => hash_prefix_len(h), + _ => None, + }; + + Ok(StorageMap { + prefix, + key_ty: key_ty.id, + value_ty: value.id, + key_offset, + }) + } + + /// Decode a map's key back out of one of its storage keys. + /// + /// `None` when the hasher discarded it, which is a property of the map + /// rather than a failure. + pub fn decode_map_key( + &self, + map: &StorageMap, + storage_key: &[u8], + ) -> Option { + let offset = 32 + map.key_offset?; + let mut cursor = storage_key.get(offset..)?; + let value = + scale_value::scale::decode_as_type(&mut cursor, map.key_ty, &self.metadata.types) + .ok()?; + let mut accounts = BTreeSet::new(); + Some(self.normalise(&value, &mut accounts)) + } + + /// Decode a map's value against the type its entry declares. + pub fn decode_map_value( + &self, + map: &StorageMap, + raw: &[u8], + ) -> Result { + let mut cursor = raw; + let value = + scale_value::scale::decode_as_type(&mut cursor, map.value_ty, &self.metadata.types) + .map_err(|e| RuntimeError::Decode(e.to_string()))?; + if !cursor.is_empty() { + return Err(RuntimeError::Decode(format!( + "{} trailing bytes in storage value", + cursor.len() + ))); + } + let mut accounts = BTreeSet::new(); + Ok(self.normalise(&value, &mut accounts)) + } + /// Decode a storage value against the type its entry declares. /// /// Normalised the same way event fields are — accounts as `0x` hex, big @@ -1594,6 +1721,63 @@ mod tests { assert!(a.default.is_some()); } + /// A map's prefix is the first 32 bytes of every key in it — pinned here + /// against the `System::Account` key the test above builds, so the two + /// cannot drift apart. + #[test] + fn a_map_prefix_is_the_head_of_its_keys() { + let rt = Runtime::from_metadata(&metadata()).expect("parses"); + let map = rt.storage_map("System", "Account").expect("declared"); + assert_eq!( + hex::encode(&map.prefix), + "26aa394eea5630e07c48ae0c9558cef7b99d880ec681799c0cf30e8886371da9" + ); + // `Blake2_128Concat`, so the account sits sixteen bytes after the + // prefix and can be read back. + assert_eq!(map.key_offset, Some(16)); + + let account = + hex::decode("c6801725b054b06a3d5030f25caa3f2e9094d7722efe4f9eddbce1227798ec7c") + .unwrap(); + let full = rt + .storage_key( + "System", + "Account", + &[scale_value::Value::from_bytes(&account)], + ) + .unwrap(); + assert!(full.key.starts_with(&map.prefix)); + + // And the round trip: the key comes back out of the storage key. + let recovered = rt.decode_map_key(&map, &full.key).expect("recoverable"); + assert_eq!( + recovered.as_str(), + Some(format!("0x{}", hex::encode(&account)).as_str()), + "{recovered:?}" + ); + } + + #[test] + fn a_plain_entry_is_not_a_map() { + let rt = Runtime::from_metadata(&metadata()).expect("parses"); + assert!(matches!( + rt.storage_map("TreasuryPallet", "TreasuryAccount"), + Err(RuntimeError::NoStorageEntry(_)) + )); + } + + /// The map this was built for. Its key is an `H256` transaction id, and it + /// must be recoverable — a pending transfer nobody can name is not much use. + #[test] + fn pending_transfers_is_an_enumerable_map() { + let rt = Runtime::from_metadata(&metadata()).expect("parses"); + let map = rt + .storage_map("ReversibleTransfers", "PendingTransfers") + .expect("declared"); + assert_eq!(map.prefix.len(), 32); + assert!(map.key_offset.is_some(), "keys must be recoverable"); + } + #[test] fn a_key_of_the_wrong_shape_is_refused() { let rt = Runtime::from_metadata(&metadata()).expect("parses"); diff --git a/crates/blackbeard-data/src/rpc.rs b/crates/blackbeard-data/src/rpc.rs index fbbbba9..cecc08e 100644 --- a/crates/blackbeard-data/src/rpc.rs +++ b/crates/blackbeard-data/src/rpc.rs @@ -205,6 +205,34 @@ impl RpcClient { Ok(self.body(hash).await?.and_then(|b| b.timestamp_ms)) } + /// Every storage key under a prefix, a page at a time. + /// + /// The only way to ask a node "what is in this map": there is no list, just + /// keys derived from the things in it, so enumeration walks the prefix. + /// + /// `start` is the last key of the previous page, exclusive. A page shorter + /// than `count` is the end. + pub async fn storage_keys_paged( + &self, + prefix: &str, + count: u32, + start: Option<&str>, + ) -> Result, DataError> { + let params = match start { + Some(s) => json!([prefix, count, s]), + None => json!([prefix, count]), + }; + let v = self.call("state_getKeysPaged", params).await?; + Ok(v.as_array() + .map(|a| { + a.iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default()) + } + /// The runtime's own description of itself, as of `hash`. /// /// The node executes `Metadata_metadata` against the runtime code in that diff --git a/crates/blackbeard-entities/src/lib.rs b/crates/blackbeard-entities/src/lib.rs index 28c5649..8f37468 100644 --- a/crates/blackbeard-entities/src/lib.rs +++ b/crates/blackbeard-entities/src/lib.rs @@ -21,6 +21,7 @@ mod call; mod chain; mod error; mod miner; +mod reversible; mod runtime; mod series; mod state; @@ -32,6 +33,7 @@ pub use call::{CallIndex, CallSummary, EventSummary}; pub use chain::{ChainId, ChainInfo, ChainStatus, ChainSummary, ClientVersion, Tracking}; pub use error::{ApiError, EntityError}; pub use miner::{AttributionSource, LeaderboardRow, MinerDetail, MinerId, MinerSeriesPoint}; +pub use reversible::{PendingTransfer, ReversibleState}; pub use runtime::{ RuntimeConstant, RuntimeDetail, RuntimeField, RuntimePallet, RuntimeSignedExtension, RuntimeStorage, RuntimeSummary, RuntimeVariant, diff --git a/crates/blackbeard-entities/src/reversible.rs b/crates/blackbeard-entities/src/reversible.rs new file mode 100644 index 0000000..3268d83 --- /dev/null +++ b/crates/blackbeard-entities/src/reversible.rs @@ -0,0 +1,84 @@ +//! Money in flight, and still cancellable. +//! +//! `ReversibleTransfers` is the most distinctive thing this chain does: a +//! transfer is scheduled rather than settled, sits for a delay, and can be +//! cancelled by its sender or by a nominated guardian before it lands. No other +//! explorer can show this, because no other chain has the pallet. +//! +//! It is also the one view here that **has to be a join of state and history**, +//! and each half is authoritative about a different thing: +//! +//! - **State** says what is still pending. A transfer that was cancelled or +//! executed is gone from `PendingTransfers`, and its absence is more +//! trustworthy than any replay of events could be. +//! - **The event index** says when each one is due, because +//! `TransactionScheduled` carries `execute_at` and the stored struct does not. +//! +//! A pending transfer whose scheduling event predates the index shows as +//! pending with an unknown deadline, rather than being dropped for lacking half +//! its story. + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::{BigUintDec, ChainId}; + +/// One scheduled transfer that has not yet landed. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "PendingTransfer.ts")] +pub struct PendingTransfer { + /// The transaction id, which is the map's own key and how the sender or a + /// guardian would refer to it when cancelling. + pub tx_id: String, + /// Who scheduled it, as `0x` hex. + pub from: Option, + /// And as an address. + pub from_address: Option, + /// Where it is going. + pub to: Option, + /// And as an address. + pub to_address: Option, + /// Who may cancel it besides the sender. + pub guardian: Option, + /// And as an address. + pub guardian_address: Option, + /// How much is frozen for it, in the chain's smallest unit. + pub amount: Option, + /// The whole decoded entry, so the named fields above are checkable and a + /// field this does not know about is still visible. + #[ts(type = "Record")] + pub entry: serde_json::Value, + /// The block it executes at, when the scheduling event is in the index. + #[ts(type = "number")] + pub execute_at: Option, + /// Blocks still to go. Negative would mean overdue, which is possible while + /// the scheduler catches up, so it is signed. + #[ts(type = "number")] + pub blocks_remaining: Option, + /// The block it was scheduled in. + #[ts(type = "number")] + pub scheduled_at_height: Option, + /// When that was, by the chain's clock. + pub scheduled_at: Option>, +} + +/// Everything currently in flight on a chain. +#[derive(Debug, Clone, Serialize, Deserialize, TS)] +#[ts(export, export_to = "ReversibleState.ts")] +pub struct ReversibleState { + /// Which chain. + pub chain: ChainId, + /// The block the state was read at, which is what the countdown counts from. + #[ts(type = "number")] + pub height: Option, + /// The chain's measured or target block interval, for turning blocks into + /// an estimate of time. Derived and moving — the block count is the fact. + pub block_seconds: Option, + /// The default delay a scheduled transfer gets, in blocks, as the runtime + /// is configured. Context for a countdown that would otherwise mean nothing. + #[ts(type = "number")] + pub default_delay_blocks: Option, + /// What is pending, soonest first. + pub pending: Vec, +} diff --git a/readme.md b/readme.md index fce4575..825ca91 100644 --- a/readme.md +++ b/readme.md @@ -190,6 +190,39 @@ become one decimal rather than four or eight little-endian limbs. Difficulty as `[1189189, 0, 0, 0, 0, 0, 0, 0]` is a correct description of the bytes and tells a reader nothing. +## Money in flight + +`/quantus/reversible` shows scheduled transfers that have not yet landed, +counting down to the block they execute at. `ReversibleTransfers` is the most +distinctive thing this chain does — a transfer waits out a delay, default 7,200 +blocks, during which its sender or a nominated guardian can call it back — and +no other explorer can show it, because no other chain has the pallet. + +It is the only view here built from **both** halves of the observer, each +authoritative about a different thing: + +- **State** — `PendingTransfers` — says what is still pending. A cancelled or + executed transfer is simply gone from the map, and that absence is more + trustworthy than replaying every event since genesis and reconstructing the + set, which fails silently when it fails. +- **The event index** says when each is due, because `TransactionScheduled` + carries `execute_at` and the stored struct does not. A transfer scheduled + before the index reaches shows as pending with an unknown deadline rather + than being dropped for lacking half its story. + +`execute_at` is a `DispatchTime` — an enum, so it names a +block on one arm and a timestamp on another, and reading the wrong arm would +present a millisecond timestamp as a height. Time remaining is an estimate from +a block interval that moves; the block count is the fact, and the page says +which is which. + +Enumerating a map needs `state_getKeysPaged` on the entry's prefix — there is no +list, only keys derived from the things in it — and recovering each key from its +storage key needs the hasher to have kept it. `Blake2_128Concat` and +`Twox64Concat` do; `Twox128` and the rest do not, so `StorageMap::key_offset` is +`None` there rather than a guess, and such a map can be counted but not +attributed. + ## State: what is true now Everything else here is history — headers, events, extrinsics, a record of what diff --git a/web/src/App.tsx b/web/src/App.tsx index 4445af2..e791ee3 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -24,6 +24,7 @@ import { Leaderboard } from './components/Leaderboard' import { MinerPanel } from './components/MinerPanel' import { RuntimePanel } from './components/RuntimePanel' import { RuntimesIndex } from './components/RuntimesIndex' +import { ReversibleIndex } from './components/ReversibleIndex' import { SectionNav } from './components/SectionNav' import { StateIndex } from './components/StateIndex' import { StatBar } from './components/StatBar' @@ -244,6 +245,13 @@ export default function App() { symbol={info?.token_symbol ?? ''} /> )} + {route.index === 'reversible' && chain && ( + + )} {route.index === 'state' && chain && ( , +/** + * The block it executes at, when the scheduling event is in the index. + */ +execute_at: number, +/** + * Blocks still to go. Negative would mean overdue, which is possible while + * the scheduler catches up, so it is signed. + */ +blocks_remaining: number, +/** + * The block it was scheduled in. + */ +scheduled_at_height: number, +/** + * When that was, by the chain's clock. + */ +scheduled_at: string | null, }; diff --git a/web/src/api/generated/ReversibleState.ts b/web/src/api/generated/ReversibleState.ts new file mode 100644 index 0000000..924e4dc --- /dev/null +++ b/web/src/api/generated/ReversibleState.ts @@ -0,0 +1,30 @@ +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ChainId } from "./ChainId"; +import type { PendingTransfer } from "./PendingTransfer"; + +/** + * Everything currently in flight on a chain. + */ +export type ReversibleState = { +/** + * Which chain. + */ +chain: ChainId, +/** + * The block the state was read at, which is what the countdown counts from. + */ +height: number, +/** + * The chain's measured or target block interval, for turning blocks into + * an estimate of time. Derived and moving — the block count is the fact. + */ +block_seconds: number | null, +/** + * The default delay a scheduled transfer gets, in blocks, as the runtime + * is configured. Context for a countdown that would otherwise mean nothing. + */ +default_delay_blocks: number, +/** + * What is pending, soonest first. + */ +pending: Array, }; diff --git a/web/src/api/rest.ts b/web/src/api/rest.ts index 146ff6f..bd86d6b 100644 --- a/web/src/api/rest.ts +++ b/web/src/api/rest.ts @@ -15,6 +15,7 @@ import type { BlockExtrinsic } from './generated/BlockExtrinsic' import type { CallIndex } from './generated/CallIndex' import type { ChainSeries } from './generated/ChainSeries' import type { ChainState } from './generated/ChainState' +import type { ReversibleState } from './generated/ReversibleState' import type { RecentBlock } from './generated/RecentBlock' import type { MinerDetail } from './generated/MinerDetail' import type { RuntimeDetail } from './generated/RuntimeDetail' @@ -158,6 +159,16 @@ export function fetchState(chain: string, signal?: AbortSignal): Promise(`/chains/${encodeURIComponent(chain)}/state`, signal) } +/** + * Scheduled transfers that have not yet executed. + * + * Live, and re-read rather than counted down locally: the deadline is a block + * height, and blocks do not arrive on a timer. + */ +export function fetchReversible(chain: string, signal?: AbortSignal): Promise { + return get(`/chains/${encodeURIComponent(chain)}/reversible`, signal) +} + /** Accounts ranked by what they have been paid, most first. */ export function fetchAccounts(chain: string, signal?: AbortSignal): Promise { return get(`/chains/${encodeURIComponent(chain)}/accounts`, signal) diff --git a/web/src/components/ReversibleIndex.tsx b/web/src/components/ReversibleIndex.tsx new file mode 100644 index 0000000..81ac1fa --- /dev/null +++ b/web/src/components/ReversibleIndex.tsx @@ -0,0 +1,210 @@ +/** + * Money in flight, and still cancellable. + * + * `ReversibleTransfers` is the most distinctive thing this chain does: a + * transfer is scheduled rather than settled, waits out a delay, and can be + * called back by its sender or by a nominated guardian before it lands. No + * other explorer can show this, because no other chain has the pallet. + * + * It is also the only view here built from **both** halves of the observer, + * each authoritative about a different thing. State says what is still pending + * — a cancelled or executed transfer is simply gone from `PendingTransfers`, + * and that absence beats any replay of events. The event index says when each + * is due, because `TransactionScheduled` carries `execute_at` and the stored + * struct does not. + */ + +import { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' + +import type { ReversibleState } from '../api/generated/ReversibleState' +import { RequestFailed, fetchReversible } from '../api/rest' +import { exactTokens, height as fmtHeight, shortAddress, tokens, when } from '../lib/format' +import { href } from '../lib/routes' + +/** Blocks as a rough span of time, said as roughly as it is meant. */ +function span(blocks: number, secondsPerBlock: number | null): string | null { + if (secondsPerBlock === null || !Number.isFinite(secondsPerBlock)) return null + const s = Math.abs(blocks) * secondsPerBlock + if (s < 90) return `~${Math.round(s)}s` + if (s < 5400) return `~${Math.round(s / 60)}m` + if (s < 172800) return `~${Math.round(s / 3600)}h` + return `~${Math.round(s / 86400)}d` +} + +export function ReversibleIndex({ + chain, + decimals, + symbol, +}: { + chain: string + decimals: number + symbol: string +}) { + const [state, setState] = useState(null) + const [error, setError] = useState(null) + + useEffect(() => { + const controller = new AbortController() + setState(null) + setError(null) + const load = () => + fetchReversible(chain, controller.signal) + .then(setState) + .catch((e: unknown) => { + if (controller.signal.aborted) return + setError(e instanceof RequestFailed ? e.message : 'Could not reach the observer.') + }) + load() + // A countdown that does not count is a screenshot. Re-read rather than + // decrementing locally: the deadline is in blocks, and blocks do not arrive + // on a timer. + const id = window.setInterval(load, 12_000) + return () => { + controller.abort() + window.clearInterval(id) + } + }, [chain]) + + return ( +
+
+

In flight

+ {state && ( + + {state.pending.length === 0 ? 'nothing pending' : `${state.pending.length} cancellable`}{' '} + · at #{fmtHeight(state.height)} + + )} +
+ + {error &&

{error}

} + {!error && state === null &&

Reading the chain…

} + + {state !== null && state.pending.length === 0 && ( + /* Not an error and not a blank. Nothing is in flight, which is a real + answer, and the page exists before the first one because catching + the first one is the point. */ +

+ Nothing is in flight. A reversible transfer waits out a delay —{' '} + {state.default_delay_blocks + ? fmtHeight(state.default_delay_blocks) + : 'a configured number of'}{' '} + blocks by default — during which its sender or its guardian can call it back. None is + pending on this chain right now. +

+ )} + + {state !== null && state.pending.length > 0 && ( +
+ + + + + + + + + + + + + + {state.pending.map((p) => ( + + + + + + + + + ))} + +
+ Scheduled transfers that have not yet executed, soonest first +
Executes in + From + + To + Amount + Guardian + + Scheduled +
+ {p.blocks_remaining === null ? ( + + unknown + + ) : p.blocks_remaining <= 0 ? ( + + overdue + + ) : ( + + {fmtHeight(p.blocks_remaining)} blocks + {span(p.blocks_remaining, state.block_seconds) && ( + + {span(p.blocks_remaining, state.block_seconds)} + + )} + + )} + + {p.from_address ? ( + + {shortAddress(p.from_address)} + + ) : ( + '—' + )} + + {p.to_address ? ( + + {shortAddress(p.to_address)} + + ) : ( + '—' + )} + + {p.amount ? `${tokens(p.amount, decimals)} ${symbol}` : '—'} + + {p.guardian_address ? ( + + {shortAddress(p.guardian_address)} + + ) : ( + '—' + )} + + {p.scheduled_at_height ? ( + + {when(p.scheduled_at)} + + ) : ( + '—' + )} +
+
+ )} + +

+ Read live from ReversibleTransfers::PendingTransfers in chain state, which is + authoritative about what is still pending — a cancelled or executed transfer is gone from + the map. The deadline comes from the TransactionScheduled event, because the + stored entry does not carry one; a transfer scheduled before the event index reaches shows + as pending with an unknown deadline rather than being dropped. Times are estimates from a + block interval that moves — the block count is the fact. +

+
+ ) +} diff --git a/web/src/lib/routes.ts b/web/src/lib/routes.ts index 45ebc65..ae47e17 100644 --- a/web/src/lib/routes.ts +++ b/web/src/lib/routes.ts @@ -56,7 +56,8 @@ export interface Route { } /** The kinds that have an index. */ -export type SectionName = 'block' | 'call' | 'event' | 'account' | 'state' | 'runtime' +export type SectionName = + 'block' | 'call' | 'event' | 'account' | 'reversible' | 'state' | 'runtime' /** * The sections, in the order the nav shows them. @@ -70,6 +71,7 @@ export const SECTIONS: { id: SectionName; label: string }[] = [ { id: 'call', label: 'Calls' }, { id: 'event', label: 'Events' }, { id: 'account', label: 'Accounts' }, + { id: 'reversible', label: 'In flight' }, { id: 'state', label: 'State' }, { id: 'runtime', label: 'Runtimes' }, ]